diff --git a/compat.go b/compat.go index 22976841..ba924c54 100644 --- a/compat.go +++ b/compat.go @@ -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 } } diff --git a/go.mod b/go.mod index dd685368..e59c1087 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 86ab0411..2b927b36 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/builtin/utils.go b/internal/builtin/utils.go index 638fcef5..1ef6785b 100644 --- a/internal/builtin/utils.go +++ b/internal/builtin/utils.go @@ -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) diff --git a/internal/builtin/utils_test.go b/internal/builtin/utils_test.go new file mode 100644 index 00000000..c115a472 --- /dev/null +++ b/internal/builtin/utils_test.go @@ -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) + } + }) + } +} diff --git a/internal/mcp/hub.go b/internal/mcp/hub.go index 7f2000bc..521af463 100644 --- a/internal/mcp/hub.go +++ b/internal/mcp/hub.go @@ -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 diff --git a/internal/version/VERSION b/internal/version/VERSION index 3c87b0d6..5d530a5f 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2505091122 +v5.0.0-beta-2505141501 diff --git a/runner.go b/runner.go index 4344d4fa..2a6f3880 100644 --- a/runner.go +++ b/runner.go @@ -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 +} diff --git a/step.go b/step.go index ac6e0f37..b1cd9d16 100644 --- a/step.go +++ b/step.go @@ -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"` } diff --git a/step_request.go b/step_request.go index 983f6e92..a596bb6c 100644 --- a/step_request.go +++ b/step_request.go @@ -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: ×, + } return s } diff --git a/step_testcase.go b/step_testcase.go index 83c4b668..f438c0ff 100644 --- a/step_testcase.go +++ b/step_testcase.go @@ -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 diff --git a/step_ui.go b/step_ui.go index 185bbb59..ae3c2217 100644 --- a/step_ui.go +++ b/step_ui.go @@ -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 } diff --git a/tests/runner_test.go b/tests/runner_test.go index e4745a5b..ba98f150 100644 --- a/tests/runner_test.go +++ b/tests/runner_test.go @@ -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)}") { diff --git a/tests/step_request_test.go b/tests/step_request_test.go index 7ba74678..3eba1459 100644 --- a/tests/step_request_test.go +++ b/tests/step_request_test.go @@ -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) diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index e7711ecf..fbfe3d9b 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -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( diff --git a/uixt/android_driver_uia2.go b/uixt/android_driver_uia2.go index 0055f764..320250cf 100644 --- a/uixt/android_driver_uia2.go +++ b/uixt/android_driver_uia2.go @@ -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 diff --git a/uixt/android_test.go b/uixt/android_test.go index be2dac5b..24eef80b 100644 --- a/uixt/android_test.go +++ b/uixt/android_test.go @@ -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) diff --git a/uixt/browser_driver.go b/uixt/browser_driver.go index 35caa7b0..a0e349ef 100644 --- a/uixt/browser_driver.go +++ b/uixt/browser_driver.go @@ -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, diff --git a/uixt/driver_action.go b/uixt/driver_action.go index 2a28a5d0..810b7233 100644 --- a/uixt/driver_action.go +++ b/uixt/driver_action.go @@ -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()...) diff --git a/uixt/driver_ext_ai.go b/uixt/driver_ext_ai.go index 286d90ee..61e0dc6a 100644 --- a/uixt/driver_ext_ai.go +++ b/uixt/driver_ext_ai.go @@ -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 } diff --git a/uixt/driver_ext_screenshot.go b/uixt/driver_ext_screenshot.go index fa10851b..ddb4c754 100644 --- a/uixt/driver_ext_screenshot.go +++ b/uixt/driver_ext_screenshot.go @@ -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)} diff --git a/uixt/driver_ext_tap.go b/uixt/driver_ext_tap.go index 2458a3a8..dcb08753 100644 --- a/uixt/driver_ext_tap.go +++ b/uixt/driver_ext_tap.go @@ -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...) } diff --git a/uixt/driver_ext_test.go b/uixt/driver_ext_test.go index 873cf52e..36250155 100644 --- a/uixt/driver_ext_test.go +++ b/uixt/driver_ext_test.go @@ -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...) diff --git a/uixt/driver_handler.go b/uixt/driver_handler.go index f237d0e8..e6ffa464 100644 --- a/uixt/driver_handler.go +++ b/uixt/driver_handler.go @@ -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 +} diff --git a/uixt/harmony_driver_hdc.go b/uixt/harmony_driver_hdc.go index 0865e365..cef8636d 100644 --- a/uixt/harmony_driver_hdc.go +++ b/uixt/harmony_driver_hdc.go @@ -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) diff --git a/uixt/ios_driver_wda.go b/uixt/ios_driver_wda.go index 889c95c0..a0708ea9 100644 --- a/uixt/ios_driver_wda.go +++ b/uixt/ios_driver_wda.go @@ -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, diff --git a/uixt/option/action.go b/uixt/option/action.go index 81911ec8..ac3ca847 100644 --- a/uixt/option/action.go +++ b/uixt/option/action.go @@ -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, diff --git a/uixt/option/screen.go b/uixt/option/screen.go index 70b4a1b3..6b90e951 100644 --- a/uixt/option/screen.go +++ b/uixt/option/screen.go @@ -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 } } diff --git a/uixt/types/field.go b/uixt/types/field.go new file mode 100644 index 00000000..2835f670 --- /dev/null +++ b/uixt/types/field.go @@ -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)) +}