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

View File

@@ -151,9 +151,6 @@ func convertCompatMobileStep(mobileUI *MobileUI) {
if ma.Method == uixt.ACTION_SwipeToTapText && actionOptions.MaxRetryTimes == 0 {
ma.ActionOptions.MaxRetryTimes = 10
}
if ma.Method == uixt.ACTION_Swipe {
ma.ActionOptions.Direction = ma.Params
}
mobileUI.Actions[i] = ma
}
}

2
go.mod
View File

@@ -27,7 +27,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/json-iterator/go v1.1.12
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/pkg/errors v0.9.1
github.com/rs/zerolog v1.33.0

4
go.sum
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/maja42/goval v1.2.1 h1:fyEgzddqPgCZsKcFLk4C6SdCHyEaAHYvtZG4mGzQOHU=
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.22.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
github.com/mark3labs/mcp-go v0.27.0 h1:iok9kU4DUIU2/XVLgFS2Q9biIDqstC0jY4EQTK2Erzc=
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.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=

View File

@@ -195,12 +195,12 @@ func Interface2Float64(i interface{}) (float64, error) {
return float64(v), nil
case float64:
return v, nil
case string:
case string: // e.g. "1", "0.5"
floatVar, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0, err
}
return floatVar, err
return floatVar, nil
}
// json.Number
value, ok := i.(builtinJSON.Number)

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)
}
})
}
}

View File

@@ -101,6 +101,7 @@ func (h *MCPHub) connectToServer(ctx context.Context, serverName string, config
switch config.TransportType {
case "sse":
mcpClient, err = client.NewSSEMCPClient(config.URL)
case "stdio", "": // default to stdio
var env []string
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,
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:
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)
}
// 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
initRequest := mcp.InitializeRequest{}
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION

View File

@@ -1 +1 @@
v5.0.0-beta-2505091122
v5.0.0-beta-2505141501

View File

@@ -11,6 +11,7 @@ import (
"os"
"os/signal"
"reflect"
"strconv"
"strings"
"syscall"
"testing"
@@ -228,7 +229,7 @@ func (r *HRPRunner) Run(testcases ...ITestCase) (err error) {
// run testcase one by one
for _, testcase := range testCases {
// each testcase has its own case runner
caseRunner, err := r.NewCaseRunner(*testcase)
caseRunner, err := NewCaseRunner(*testcase, r)
if err != nil {
log.Error().Err(err).Msg("[Run] init case runner failed")
return err
@@ -278,10 +279,14 @@ func (r *HRPRunner) Run(testcases ...ITestCase) (err error) {
// NewCaseRunner creates a new case runner for testcase.
// 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{
TestCase: testcase,
hrpRunner: r,
hrpRunner: hrpRunner,
parser: NewParser(),
uixtDrivers: make(map[string]*uixt.XTDriver),
}
@@ -289,7 +294,7 @@ func (r *HRPRunner) NewCaseRunner(testcase TestCase) (*CaseRunner, error) {
// init parser plugin
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 {
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
if config.RequestTimeout != 0 {
r.SetRequestTimeout(config.RequestTimeout)
if parsedConfig.RequestTimeout != 0 {
hrpRunner.SetRequestTimeout(parsedConfig.RequestTimeout)
}
// set testcase timeout in seconds
if config.CaseTimeout != 0 {
r.SetCaseTimeout(config.CaseTimeout)
if parsedConfig.CaseTimeout != 0 {
hrpRunner.SetCaseTimeout(parsedConfig.CaseTimeout)
}
caseRunner.TestCase.Config = parsedConfig
@@ -450,7 +455,7 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
if err != nil {
return nil, errors.Wrap(err, "init android XTDriver failed")
}
r.uixtDrivers[androidDeviceOptions.SerialNumber] = driverExt
r.RegisterUIXTDriver(androidDeviceOptions.SerialNumber, driverExt)
}
// parse iOS devices config
for _, iosDeviceOptions := range parsedConfig.IOS {
@@ -473,7 +478,7 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
if err != nil {
return nil, errors.Wrap(err, "init ios XTDriver failed")
}
r.uixtDrivers[iosDeviceOptions.UDID] = driverExt
r.RegisterUIXTDriver(iosDeviceOptions.UDID, driverExt)
}
// parse harmony devices config
for _, harmonyDeviceOptions := range parsedConfig.Harmony {
@@ -496,7 +501,7 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
if err != nil {
return nil, errors.Wrap(err, "init harmony XTDriver failed")
}
r.uixtDrivers[harmonyDeviceOptions.ConnectKey] = driverExt
r.RegisterUIXTDriver(harmonyDeviceOptions.ConnectKey, driverExt)
}
// parse browser devices config
for _, browserDeviceOptions := range parsedConfig.Browser {
@@ -509,26 +514,24 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
if err != nil {
return nil, errors.Wrap(err, "init browser device failed")
}
if err := device.Setup(); err != nil {
return nil, err
}
driver, err := device.NewDriver()
if err != nil {
return nil, err
}
if err := driver.Setup(); err != nil {
return nil, err
return nil, errors.Wrap(err, "init browser driver failed")
}
driverExt, err := uixt.NewXTDriver(driver, aiOpts...)
if err != nil {
return nil, errors.Wrap(err, "init browser XTDriver failed")
}
r.uixtDrivers[browserDeviceOptions.BrowserID] = driverExt
r.RegisterUIXTDriver(browserDeviceOptions.BrowserID, driverExt)
}
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 {
deviceValue := reflect.ValueOf(device).Elem()
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")
// run times of step
loopTimes := step.Config().Loops
if loopTimes < 0 {
log.Warn().Int("loops", loopTimes).Msg("loop times should be positive, set to 1")
loopTimes = 1
} else if loopTimes == 0 {
loopTimes = 1
} else if loopTimes > 1 {
log.Info().Int("loops", loopTimes).Msg("run step with specified loop times")
loopTimes, err := r.getLoopTimes(step)
if err != nil {
return nil, errors.Wrap(err, "failed to get 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 {
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
}

View File

@@ -1,6 +1,9 @@
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
@@ -31,7 +34,7 @@ type StepConfig struct {
Extract map[string]string `json:"extract,omitempty" yaml:"extract,omitempty"`
Validators []interface{} `json:"validate,omitempty" yaml:"validate,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"`
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/httpstat"
"github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/httprunner/httprunner/v5/uixt/types"
)
type HTTPMethod string
@@ -559,7 +560,9 @@ func (s *StepRequest) HTTP2() *StepRequest {
// Loop specify running times for the current step
func (s *StepRequest) Loop(times int) *StepRequest {
s.Loops = times
s.Loops = &types.IntOrString{
IntValue: &times,
}
return s
}

View File

@@ -80,7 +80,7 @@ func (s *StepTestCaseWithOptionalArgs) Run(r *SessionRunner) (stepResult *StepRe
// merge & override extractors
config.Export = mergeSlices(s.StepExport, config.Export)
caseRunner, err := r.caseRunner.hrpRunner.NewCaseRunner(*copiedTestCase)
caseRunner, err := NewCaseRunner(*copiedTestCase, r.caseRunner.hrpRunner)
if err != nil {
log.Error().Err(err).Msg("create case runner failed")
return stepResult, err

View File

@@ -447,12 +447,12 @@ func (s *StepMobile) ClosePopups(opts ...option.ActionOption) *StepMobile {
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{
Method: uixt.ACTION_CallFunction,
Params: name, // function description
Fn: fn,
Options: nil,
Options: option.NewActionOptions(opts...),
})
return s
}

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()
step := testcase.TestSteps[0]
if !assert.Equal(t, step.Config().Variables["varFoo"], "${max($a, $b)}") {

View File

@@ -69,7 +69,7 @@ func TestRunRequestStatOn(t *testing.T) {
Config: hrp.NewConfig("test").SetBaseURL("https://postman-echo.com"),
TestSteps: []hrp.IStep{stepGET, stepPOSTData},
}
caseRunner, _ := hrp.NewRunner(t).SetHTTPStatOn().NewCaseRunner(testcase)
caseRunner, _ := hrp.NewCaseRunner(testcase, hrp.NewRunner(t).SetHTTPStatOn())
sessionRunner := caseRunner.NewSession()
summary, err := sessionRunner.Start(nil)
assert.Nil(t, err)

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 {
log.Info().Float64("x", x).Float64("y", y).Msg("ADBDriver.TapAbsXY")
var err error
x, y, err = handlerTapAbsXY(ad, x, y, opts...)
actionOptions := option.NewActionOptions(opts...)
x, y, err := preHandler_TapAbsXY(ad, actionOptions, x, y)
if err != nil {
return err
}
defer postHandler(ad, ACTION_TapAbsXY, actionOptions)
// adb shell input tap x y
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 {
log.Info().Float64("x", x).Float64("y", y).Msg("ADBDriver.DoubleTap")
var err error
x, y, err = handlerDoubleTap(ad, x, y, opts...)
actionOptions := option.NewActionOptions(opts...)
x, y, err := preHandler_DoubleTap(ad, actionOptions, x, y)
if err != nil {
return err
}
defer postHandler(ad, ACTION_DoubleTapXY, actionOptions)
// adb shell input tap x y
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).
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 {
return err
}
defer postHandler(ad, ACTION_Drag, actionOptions)
actionOptions := option.NewActionOptions(opts...)
duration := 200.0
if actionOptions.Duration > 0 {
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 {
log.Info().Float64("fromX", fromX).Float64("fromY", fromY).
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 {
return err
}
defer postHandler(ad, ACTION_Swipe, actionOptions)
// adb shell input swipe fromX fromY toX toY
_, err = ad.runShellCommand(

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 {
log.Info().Float64("x", x).Float64("y", y).Msg("UIA2Driver.DoubleTap")
var err error
x, y, err = handlerDoubleTap(ud, x, y, opts...)
actionOptions := option.NewActionOptions(opts...)
x, y, err := preHandler_DoubleTap(ud, actionOptions, x, y)
if err != nil {
return err
}
defer postHandler(ud, ACTION_DoubleTapXY, actionOptions)
data := map[string]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 {
log.Info().Float64("x", x).Float64("y", y).Msg("UIA2Driver.TapAbsXY")
// register(postHandler, new Tap("/wd/hub/session/:sessionId/appium/tap"))
var err error
x, y, err = handlerTapAbsXY(ud, x, y, opts...)
actionOptions := option.NewActionOptions(opts...)
x, y, err := preHandler_TapAbsXY(ud, actionOptions, x, y)
if err != nil {
return err
}
defer postHandler(ud, ACTION_TapAbsXY, actionOptions)
actionOptions := option.NewActionOptions(opts...)
duration := 100.0
if actionOptions.PressDuration > 0 {
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).
Float64("toX", toX).Float64("toY", toY).Msg("UIA2Driver.Drag")
var err error
fromX, fromY, toX, toY, err = handlerDrag(ud, fromX, fromY, toX, toY, opts...)
actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err := preHandler_Drag(ud, actionOptions, fromX, fromY, toX, toY)
if err != nil {
return err
}
defer postHandler(ud, ACTION_Drag, actionOptions)
data := map[string]interface{}{
"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"))
log.Info().Float64("fromX", fromX).Float64("fromY", fromY).
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 {
return err
}
actionOptions := option.NewActionOptions(opts...)
defer postHandler(ud, ACTION_Swipe, actionOptions)
duration := 200.0
if actionOptions.PressDuration > 0 {
duration = actionOptions.PressDuration * 1000 // ms

View File

@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -133,6 +134,23 @@ func TestDriver_ADB_TapXY(t *testing.T) {
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) {
driver := setupADBDriverExt(t)
err := driver.TapAbsXY(100, 300)

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) {
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 {
return err
}
defer postHandler(wd, ACTION_Drag, actionOptions)
data := map[string]interface{}{
"from_x": fromX,
"from_y": fromY,
"to_x": toX,
"to_y": toY,
}
actionOptions := option.NewActionOptions(options...)
if actionOptions.Duration > 0 {
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 {
var err error
x, y, err = handlerTapAbsXY(wd, x, y, opts...)
actionOptions := option.NewActionOptions(opts...)
x, y, err := preHandler_TapAbsXY(wd, actionOptions, x, y)
if err != nil {
return err
}
defer postHandler(wd, ACTION_TapAbsXY, actionOptions)
actionOptions := option.NewActionOptions(opts...)
duration := 0.1
if actionOptions.Duration > 0 {
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.
func (wd *BrowserDriver) DoubleTap(x, y float64, options ...option.ActionOption) error {
var err error
x, y, err = handlerDoubleTap(wd, x, y, options...)
actionOptions := option.NewActionOptions(options...)
x, y, err := preHandler_DoubleTap(wd, actionOptions, x, y)
if err != nil {
return err
}
defer postHandler(wd, ACTION_DoubleTapXY, actionOptions)
data := map[string]interface{}{
"x": x,
"y": y,

View File

@@ -87,7 +87,7 @@ const (
type MobileAction struct {
Method ActionMethod `json:"method,omitempty" yaml:"method,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"`
option.ActionOptions
}
@@ -306,6 +306,13 @@ func (dExt *XTDriver) DoAction(action MobileAction) (err error) {
} else if sd, ok := action.Params.(SleepConfig); ok {
sleepStrict(sd.StartTime, int64(sd.Seconds*1000))
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)
case ACTION_SleepMS:
@@ -335,9 +342,10 @@ func (dExt *XTDriver) DoAction(action MobileAction) (err error) {
case ACTION_ClosePopups:
return dExt.ClosePopupsHandler()
case ACTION_CallFunction:
fn := action.Fn
fn()
return nil
if funcDesc, ok := action.Params.(string); ok {
return dExt.Call(funcDesc, action.Fn, action.GetOptions()...)
}
return fmt.Errorf("invalid function description: %v", action.Params)
case ACTION_AIAction:
if prompt, ok := action.Params.(string); ok {
return dExt.AIAction(prompt, action.GetOptions()...)

View File

@@ -60,7 +60,7 @@ func (dExt *XTDriver) PlanNextAction(text string, opts ...option.ActionOption) (
return nil, errors.New("LLM service is not initialized")
}
compressedBufSource, err := dExt.GetScreenShotBuffer()
compressedBufSource, err := getScreenShotBuffer(dExt.IDriver)
if err != nil {
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")
}
compressedBufSource, err := dExt.GetScreenShotBuffer()
compressedBufSource, err := getScreenShotBuffer(dExt.IDriver)
if err != nil {
return err
}

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
func (dExt *XTDriver) GetScreenResult(opts ...option.ActionOption) (screenResult *ScreenResult, err error) {
// get compressed screenshot buffer
compressBufSource, err := dExt.GetScreenShotBuffer()
compressBufSource, err := getScreenShotBuffer(dExt.IDriver)
if err != nil {
return nil, err
}
@@ -220,6 +202,25 @@ func (dExt *XTDriver) FindUIResult(opts ...option.ActionOption) (uiResult ai.UIR
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
func saveScreenShot(raw *bytes.Buffer, screenshotPath string) error {
// 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
timestamp := builtin.GenNameWithTimestamp("action_%d")
var imagePath string
timestamp := builtin.GenNameWithTimestamp("%d")
imagePath := filepath.Join(
config.GetConfig().ScreenShotsPath,
fmt.Sprintf("action_%s_pre_%s.png", timestamp, actionType),
)
if actionType == ACTION_TapAbsXY || actionType == ACTION_DoubleTapXY {
if len(actionCoordinates) != 2 {
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]
point := image.Point{X: int(x), Y: int(y)}
err = SaveImageWithCircleMarker(compressedBufSource, point, imagePath)
@@ -332,10 +332,6 @@ func MarkUIOperation(driver IDriver, actionType ActionMethod, actionCoordinates
if len(actionCoordinates) != 4 {
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]
toX, toY := actionCoordinates[2], actionCoordinates[3]
from := image.Point{X: int(fromX), Y: int(fromY)}

View File

@@ -29,7 +29,7 @@ func (dExt *XTDriver) TapByOCR(text string, opts ...option.ActionOption) error {
point = textRect.Center()
}
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...)
}
@@ -52,7 +52,7 @@ func (dExt *XTDriver) TapByCV(opts ...option.ActionOption) error {
point = uiResult.Center()
}
log.Info().Interface("rawUIResult", uiResult).
Interface("tapPoint", point).Msg("TapByCV success")
Interface("tapPoint", point).Msg("TapByCV")
return dExt.TapAbsXY(point.X, point.Y, opts...)
}

View File

@@ -287,7 +287,7 @@ func TestSaveImageWithArrow(t *testing.T) {
func TestMarkOperation(t *testing.T) {
driver := setupDriverExt(t)
opts := []option.ActionOption{option.WithMarkOperationEnabled(true)}
opts := []option.ActionOption{option.WithPreMarkOperation(true)}
// tap point
err := driver.TapXY(0.5, 0.5, opts...)

View File

@@ -1,18 +1,56 @@
package uixt
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/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) {
actionOptions := option.NewActionOptions(opts...)
x, y = actionOptions.ApplyTapOffset(rawX, rawY)
x, y = options.ApplyTapOffset(rawX, rawY)
// mark UI operation
if actionOptions.MarkOperationEnabled {
if options.PreMarkOperation {
if markErr := MarkUIOperation(driver, ACTION_TapAbsXY, []float64{x, y}); markErr != nil {
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
}
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, err = convertToAbsolutePoint(driver, rawX, rawY)
@@ -29,11 +67,10 @@ func handlerDoubleTap(driver IDriver, rawX, rawY float64, opts ...option.ActionO
return 0, 0, err
}
actionOptions := option.NewActionOptions(opts...)
x, y = actionOptions.ApplyTapOffset(x, y)
x, y = options.ApplyTapOffset(x, y)
// mark UI operation
if actionOptions.MarkOperationEnabled {
if options.PreMarkOperation {
if markErr := MarkUIOperation(driver, ACTION_DoubleTapXY, []float64{x, y}); markErr != nil {
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
}
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) {
actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(driver, rawFomX, rawFromY, rawToX, rawToY)
if err != nil {
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
if actionOptions.MarkOperationEnabled {
if options.PreMarkOperation {
if markErr := MarkUIOperation(driver, ACTION_Drag, []float64{fromX, fromY, toX, toY}); markErr != nil {
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
}
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) {
actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(driver, rawFomX, rawFromY, rawToX, rawToY)
if err != nil {
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
if actionOptions.MarkOperationEnabled {
// save screenshot before action and mark UI operation
if options.PreMarkOperation {
if markErr := MarkUIOperation(driver, ACTION_Swipe, []float64{fromX, fromY, toX, toY}); markErr != nil {
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
}
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
}

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 {
log.Info().Float64("x", x).Float64("y", y).Msg("HDCDriver.TapAbsXY")
var err error
x, y, err = handlerTapAbsXY(hd, x, y, opts...)
actionOptions := option.NewActionOptions(opts...)
x, y, err := preHandler_TapAbsXY(hd, actionOptions, x, y)
if err != nil {
return err
}
defer postHandler(hd, ACTION_TapAbsXY, actionOptions)
actionOptions := option.NewActionOptions(opts...)
if actionOptions.Identifier != "" {
startTime := int(time.Now().UnixMilli())
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 {
log.Info().Float64("fromX", fromX).Float64("fromY", fromY).
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 {
return err
}
actionOptions := option.NewActionOptions(opts...)
defer postHandler(hd, ACTION_Swipe, actionOptions)
duration := 200
if actionOptions.PressDuration > 0 {
duration = int(actionOptions.PressDuration * 1000)

View File

@@ -597,11 +597,12 @@ func (wd *WDADriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
x = wd.toScale(x)
y = wd.toScale(y)
var err error
x, y, err = handlerTapAbsXY(wd, x, y, opts...)
actionOptions := option.NewActionOptions(opts...)
x, y, err := preHandler_TapAbsXY(wd, actionOptions, x, y)
if err != nil {
return err
}
defer postHandler(wd, ACTION_TapAbsXY, actionOptions)
data := map[string]interface{}{
"x": x,
@@ -621,11 +622,12 @@ func (wd *WDADriver) DoubleTap(x, y float64, opts ...option.ActionOption) error
x = wd.toScale(x)
y = wd.toScale(y)
var err error
x, y, err = handlerDoubleTap(wd, x, y, opts...)
actionOptions := option.NewActionOptions(opts...)
x, y, err := preHandler_DoubleTap(wd, actionOptions, x, y)
if err != nil {
return err
}
defer postHandler(wd, ACTION_DoubleTapXY, actionOptions)
data := map[string]interface{}{
"x": x,
@@ -657,11 +659,12 @@ func (wd *WDADriver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionO
toX = wd.toScale(toX)
toY = wd.toScale(toY)
var err error
fromX, fromY, toX, toY, err = handlerDrag(wd, fromX, fromY, toX, toY, opts...)
actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err := preHandler_Drag(wd, actionOptions, fromX, fromY, toX, toY)
if err != nil {
return err
}
defer postHandler(wd, ACTION_Drag, actionOptions)
data := map[string]interface{}{
"fromX": math.Round(fromX*10) / 10,

View File

@@ -5,6 +5,7 @@ import (
"math/rand/v2"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/rs/zerolog/log"
)
type ActionOptions struct {
@@ -72,10 +73,22 @@ func (o *ActionOptions) Options() []ActionOption {
case []interface{}:
// loaded from json case
// custom direction: [fromX, fromY, toX, toY]
sx, _ := builtin.Interface2Float64(v[0])
sy, _ := builtin.Interface2Float64(v[1])
ex, _ := builtin.Interface2Float64(v[2])
ey, _ := builtin.Interface2Float64(v[3])
sx, err := builtin.Interface2Float64(v[0])
if err != nil {
log.Error().Err(err).Interface("fromX", v[0]).Msg("convert float64 failed")
}
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(
sx, sy,
ex, ey,

View File

@@ -277,8 +277,8 @@ func WithIndex(index int) ActionOption {
// MarkOperationOptions contains options for marking UI operations
type MarkOperationOptions struct {
// mark UI operation, enable/disable UI operation marking
MarkOperationEnabled bool `json:"mark_operation_enabled,omitempty" yaml:"mark_operation_enabled,omitempty"`
PreMarkOperation bool `json:"pre_mark_operation,omitempty" yaml:"pre_mark_operation,omitempty"`
PostMarkOperation bool `json:"post_mark_operation,omitempty" yaml:"post_mark_operation,omitempty"`
}
func (o *MarkOperationOptions) GetMarkOperationOptions() []ActionOption {
@@ -287,16 +287,26 @@ func (o *MarkOperationOptions) GetMarkOperationOptions() []ActionOption {
return options
}
if o.MarkOperationEnabled {
options = append(options, WithMarkOperationEnabled(true))
if o.PreMarkOperation {
options = append(options, WithPreMarkOperation(true))
}
if o.PostMarkOperation {
options = append(options, WithPostMarkOperation(true))
}
return options
}
// WithMarkOperationEnabled enables or disables UI operation marking
func WithMarkOperationEnabled(enabled bool) ActionOption {
// WithPreMarkOperation enables UI operation marking before action
func WithPreMarkOperation(enabled bool) ActionOption {
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
uixt/types/field.go Normal file
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))
}