mirror of
https://github.com/httprunner/httprunner.git
synced 2026-06-26 10:01:28 +08:00
Merge branch 'dev' into 'master'
release v5.0.0 See merge request iesqa/httprunner!107
This commit is contained in:
@@ -36,6 +36,7 @@ var (
|
||||
genHTMLReport bool
|
||||
caseTimeout float32
|
||||
runMCPConfigPath string // MCP config path for run command
|
||||
autoPopupHandler bool // enable auto popup handler for all steps
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -48,6 +49,7 @@ func init() {
|
||||
CmdRun.Flags().BoolVarP(&genHTMLReport, "gen-html-report", "g", false, "generate html report")
|
||||
CmdRun.Flags().Float32Var(&caseTimeout, "case-timeout", 3600, "set testcase timeout (seconds)")
|
||||
CmdRun.Flags().StringVar(&runMCPConfigPath, "mcp-config", "", "path to the MCP config file")
|
||||
CmdRun.Flags().BoolVar(&autoPopupHandler, "enable-auto-popup-handler", false, "enable auto popup handler for all UI steps")
|
||||
}
|
||||
|
||||
func makeHRPRunner() *hrp.HRPRunner {
|
||||
@@ -76,5 +78,8 @@ func makeHRPRunner() *hrp.HRPRunner {
|
||||
if runMCPConfigPath != "" {
|
||||
runner.SetMCPConfigPath(runMCPConfigPath)
|
||||
}
|
||||
if autoPopupHandler {
|
||||
runner.EnableAutoPopupHandler(autoPopupHandler)
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
@@ -63,4 +63,4 @@ Copyright © 2017-present debugtalk. Apache-2.0 License.
|
||||
* [hrp startproject](hrp_startproject.md) - Create a scaffold project
|
||||
* [hrp wiki](hrp_wiki.md) - visit https://httprunner.com
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -23,4 +23,4 @@ simple utils for android device management
|
||||
* [hrp adb install](hrp_adb_install.md) - push package to the device and install them automatically
|
||||
* [hrp adb screencap](hrp_adb_screencap.md) - Start android screen capture
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -24,4 +24,4 @@ hrp adb devices [flags]
|
||||
|
||||
* [hrp adb](hrp_adb.md) - simple utils for android device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -28,4 +28,4 @@ hrp adb install [flags] PACKAGE
|
||||
|
||||
* [hrp adb](hrp_adb.md) - simple utils for android device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -25,4 +25,4 @@ hrp adb screencap [flags]
|
||||
|
||||
* [hrp adb](hrp_adb.md) - simple utils for android device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -36,4 +36,4 @@ hrp build $path ... [flags]
|
||||
|
||||
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -34,4 +34,4 @@ hrp convert $path... [flags]
|
||||
|
||||
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -29,4 +29,4 @@ simple utils for ios device management
|
||||
* [hrp ios uninstall](hrp_ios_uninstall.md) - uninstall package automatically
|
||||
* [hrp ios xctest](hrp_ios_xctest.md) - run xctest
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -26,4 +26,4 @@ hrp ios apps [flags]
|
||||
|
||||
* [hrp ios](hrp_ios.md) - simple utils for ios device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -24,4 +24,4 @@ hrp ios devices [flags]
|
||||
|
||||
* [hrp ios](hrp_ios.md) - simple utils for ios device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -25,4 +25,4 @@ hrp ios install [flags] PACKAGE
|
||||
|
||||
* [hrp ios](hrp_ios.md) - simple utils for ios device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -28,4 +28,4 @@ hrp ios mount [flags]
|
||||
|
||||
* [hrp ios](hrp_ios.md) - simple utils for ios device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -26,4 +26,4 @@ hrp ios ps [flags]
|
||||
|
||||
* [hrp ios](hrp_ios.md) - simple utils for ios device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -25,4 +25,4 @@ hrp ios reboot [flags]
|
||||
|
||||
* [hrp ios](hrp_ios.md) - simple utils for ios device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -24,4 +24,4 @@ hrp ios tunnel [flags]
|
||||
|
||||
* [hrp ios](hrp_ios.md) - simple utils for ios device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -26,4 +26,4 @@ hrp ios uninstall [flags] PACKAGE
|
||||
|
||||
* [hrp ios](hrp_ios.md) - simple utils for ios device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -28,4 +28,4 @@ hrp ios xctest [flags]
|
||||
|
||||
* [hrp ios](hrp_ios.md) - simple utils for ios device management
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -28,4 +28,4 @@ hrp mcp-server [flags]
|
||||
|
||||
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -31,4 +31,4 @@ hrp mcphost [flags]
|
||||
|
||||
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -24,4 +24,4 @@ hrp pytest $path ... [flags]
|
||||
|
||||
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -33,4 +33,4 @@ hrp report [result_folder] [flags]
|
||||
|
||||
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -21,16 +21,17 @@ hrp run $path... [flags]
|
||||
### Options
|
||||
|
||||
```
|
||||
--case-timeout float32 set testcase timeout (seconds) (default 3600)
|
||||
-c, --continue-on-failure continue running next step when failure occurs
|
||||
-g, --gen-html-report generate html report
|
||||
-h, --help help for run
|
||||
--http-stat turn on HTTP latency stat (DNSLookup, TCP Connection, etc.)
|
||||
--log-plugin turn on plugin logging
|
||||
--log-requests-off turn off request & response details logging
|
||||
--mcp-config string path to the MCP config file
|
||||
-p, --proxy-url string set proxy url
|
||||
-s, --save-tests save tests summary
|
||||
--case-timeout float32 set testcase timeout (seconds) (default 3600)
|
||||
-c, --continue-on-failure continue running next step when failure occurs
|
||||
--enable-auto-popup-handler enable auto popup handler for all UI steps
|
||||
-g, --gen-html-report generate html report
|
||||
-h, --help help for run
|
||||
--http-stat turn on HTTP latency stat (DNSLookup, TCP Connection, etc.)
|
||||
--log-plugin turn on plugin logging
|
||||
--log-requests-off turn off request & response details logging
|
||||
--mcp-config string path to the MCP config file
|
||||
-p, --proxy-url string set proxy url
|
||||
-s, --save-tests save tests summary
|
||||
```
|
||||
|
||||
### Options inherited from parent commands
|
||||
@@ -45,4 +46,4 @@ hrp run $path... [flags]
|
||||
|
||||
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -30,4 +30,4 @@ hrp server start [flags]
|
||||
|
||||
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -29,4 +29,4 @@ hrp startproject $project_name [flags]
|
||||
|
||||
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
@@ -24,4 +24,4 @@ hrp wiki [flags]
|
||||
|
||||
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
|
||||
|
||||
###### Auto generated by spf13/cobra on 8-Jun-2025
|
||||
###### Auto generated by spf13/cobra on 27-Jun-2025
|
||||
|
||||
46
examples/game/2048/game_2048.json
Normal file
46
examples/game/2048/game_2048.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"config": {
|
||||
"name": "2048 小游戏自动化测试",
|
||||
"ai_options": {
|
||||
"llm_service": "doubao-1.5-ui-tars-250328"
|
||||
}
|
||||
},
|
||||
"teststeps": [
|
||||
{
|
||||
"name": "启动抖音「2048经典」小游戏",
|
||||
"android": {
|
||||
"os_type": "android",
|
||||
"actions": [
|
||||
{
|
||||
"method": "start_to_goal",
|
||||
"params": "启动抖音,搜索「2048经典」小游戏,并启动游戏",
|
||||
"options": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"validate": [
|
||||
{
|
||||
"check": "ui_ai",
|
||||
"assert": "ai_assert",
|
||||
"expect": "当前位于抖音「2048」小游戏页面",
|
||||
"msg": "assert ai prompt [当前位于抖音「2048」小游戏页面] failed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "开始游戏",
|
||||
"android": {
|
||||
"os_type": "android",
|
||||
"actions": [
|
||||
{
|
||||
"method": "start_to_goal",
|
||||
"params": "2048 是一款数字合并类的益智小游戏,以下是它的基本规则:\n1、游戏目标:在一个 4x4 的网格中,通过合并相同数字的方块,最终得到一个数值为 2048 的方块。当然,若你能继续合并,也可追求更高的数字。\n2、初始状态:游戏开始时,网格中会随机出现两个数字为 2 或 4 的方块。\n3、移动操作:玩家可以选择上、下、左、右四个方向进行移动。每次移动时,所有方块会朝着指定方向滑动,直到碰到边界或其他方块。\n4、合并规则:当两个相同数字的方块在移动过程中相遇时,它们会合并成一个新的方块,新方块的数值为原来两个方块数值之和。例如,两个 2 合并成一个 4,两个 4 合并成一个 8,依此类推。\n5、新方块生成:每次移动结束后,网格中会随机出现一个新的数字为 2 或 4 的方块。\n6、注意事项:若连续多次滑动无法生效,请调整策略;例如,向上无法滑动,可以尝试向下滑;向左无法滑动,可以尝试向右滑。\n7、游戏结束:当网格被填满,且没有可合并的方块时,游戏结束,停止游戏。\n\n请严格按照以上游戏规则,开始游戏\n",
|
||||
"options": {
|
||||
"max_retry_times": 100
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
43
examples/game/2048/main_test.go
Normal file
43
examples/game/2048/main_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package game_2048
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
hrp "github.com/httprunner/httprunner/v5"
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGame2048(t *testing.T) {
|
||||
userInstruction := `2048 是一款数字合并类的益智小游戏,以下是它的基本规则:
|
||||
1、游戏目标:在一个 4x4 的网格中,通过合并相同数字的方块,最终得到一个数值为 2048 的方块。当然,若你能继续合并,也可追求更高的数字。
|
||||
2、初始状态:游戏开始时,网格中会随机出现两个数字为 2 或 4 的方块。
|
||||
3、移动操作:玩家可以选择上、下、左、右四个方向进行移动。每次移动时,所有方块会朝着指定方向滑动,直到碰到边界或其他方块。
|
||||
4、合并规则:当两个相同数字的方块在移动过程中相遇时,它们会合并成一个新的方块,新方块的数值为原来两个方块数值之和。例如,两个 2 合并成一个 4,两个 4 合并成一个 8,依此类推。
|
||||
5、新方块生成:每次移动结束后,网格中会随机出现一个新的数字为 2 或 4 的方块。
|
||||
6、注意事项:若连续多次滑动无法生效,请调整策略;例如,向上无法滑动,可以尝试向下滑;向左无法滑动,可以尝试向右滑。
|
||||
7、游戏结束:当网格被填满,且没有可合并的方块时,游戏结束,停止游戏。
|
||||
|
||||
请严格按照以上游戏规则,开始游戏
|
||||
`
|
||||
|
||||
testCase := &hrp.TestCase{
|
||||
Config: hrp.NewConfig("2048 小游戏自动化测试").
|
||||
SetLLMService(option.DOUBAO_1_5_UI_TARS_250328),
|
||||
TestSteps: []hrp.IStep{
|
||||
hrp.NewStep("启动抖音「2048经典」小游戏").
|
||||
Android().
|
||||
StartToGoal("启动抖音,搜索「2048经典」小游戏,并启动游戏").
|
||||
Validate().
|
||||
AssertAI("当前位于抖音「2048」小游戏页面"),
|
||||
hrp.NewStep("开始游戏").
|
||||
Android().
|
||||
StartToGoal(userInstruction, option.WithMaxRetryTimes(100)),
|
||||
},
|
||||
}
|
||||
err := testCase.Dump2JSON("game_2048.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
// err = hrp.NewRunner(t).Run(testCase)
|
||||
// assert.Nil(t, err)
|
||||
}
|
||||
46
examples/game/llk/game_llk.json
Normal file
46
examples/game/llk/game_llk.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"config": {
|
||||
"name": "连连看小游戏自动化测试",
|
||||
"ai_options": {
|
||||
"llm_service": "doubao-1.5-thinking-vision-pro-250428"
|
||||
}
|
||||
},
|
||||
"teststeps": [
|
||||
{
|
||||
"name": "启动抖音「连了又连」小游戏",
|
||||
"android": {
|
||||
"os_type": "android",
|
||||
"actions": [
|
||||
{
|
||||
"method": "start_to_goal",
|
||||
"params": "启动抖音,搜索「连了又连」小游戏,并启动游戏",
|
||||
"options": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"validate": [
|
||||
{
|
||||
"check": "ui_ai",
|
||||
"assert": "ai_assert",
|
||||
"expect": "当前位于抖音「连了又连」小游戏页面",
|
||||
"msg": "assert ai prompt [当前位于抖音「连了又连」小游戏页面] failed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "开始游戏",
|
||||
"android": {
|
||||
"os_type": "android",
|
||||
"actions": [
|
||||
{
|
||||
"method": "start_to_goal",
|
||||
"params": "连连看是一款经典的益智消除类小游戏,通常以图案或图标为主要元素。以下是连连看的基本规则说明:\n1. 游戏目标: 玩家需要通过连接相同的图案或图标,将它们从游戏界面中消除。\n2. 连接规则:\n- 两个相同的图案可以通过不超过三条直线连接。\n- 连接线可以水平或垂直,但不能斜线,也不能跨过其他图案。\n- 连接线的转折次数不能超过两次。\n3. 游戏界面:\n- 游戏界面是一个矩形区域,内含多个图案或图标,排列成行和列;图案或图标在未选中状态下背景为白色,选中状态下背景为绿色。\n- 游戏界面下方是道具区域,共有 3 种道具,从左到右分别是:「高亮显示」、「随机打乱」、「减少种类」。\n4、游戏攻略:建议多次使用道具,可以降低游戏难度\n- 优先使用「减少种类」道具,可以将图案种类随机减少一种\n- 遇到困难时,推荐使用「随机打乱」道具,可以获得很多新的消除机会\n- 观看广告视频,待屏幕右上角出现「领取成功」后,点击其右侧的 X 即可关闭广告,继续游戏\n\n请严格按照以上游戏规则,开始游戏\n",
|
||||
"options": {
|
||||
"max_retry_times": 100
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
hrp "github.com/httprunner/httprunner/v5"
|
||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
||||
"github.com/httprunner/httprunner/v5/uixt/ai"
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
@@ -15,6 +16,45 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGameLianliankan(t *testing.T) {
|
||||
userInstruction := `连连看是一款经典的益智消除类小游戏,通常以图案或图标为主要元素。以下是连连看的基本规则说明:
|
||||
1. 游戏目标: 玩家需要通过连接相同的图案或图标,将它们从游戏界面中消除。
|
||||
2. 连接规则:
|
||||
- 两个相同的图案可以通过不超过三条直线连接。
|
||||
- 连接线可以水平或垂直,但不能斜线,也不能跨过其他图案。
|
||||
- 连接线的转折次数不能超过两次。
|
||||
3. 游戏界面:
|
||||
- 游戏界面是一个矩形区域,内含多个图案或图标,排列成行和列;图案或图标在未选中状态下背景为白色,选中状态下背景为绿色。
|
||||
- 游戏界面下方是道具区域,共有 3 种道具,从左到右分别是:「高亮显示」、「随机打乱」、「减少种类」。
|
||||
4、游戏攻略:建议多次使用道具,可以降低游戏难度
|
||||
- 优先使用「减少种类」道具,可以将图案种类随机减少一种
|
||||
- 遇到困难时,推荐使用「随机打乱」道具,可以获得很多新的消除机会
|
||||
- 观看广告视频,待屏幕右上角出现「领取成功」后,点击其右侧的 X 即可关闭广告,继续游戏
|
||||
|
||||
请严格按照以上游戏规则,开始游戏
|
||||
`
|
||||
|
||||
testCase := &hrp.TestCase{
|
||||
Config: hrp.NewConfig("连连看小游戏自动化测试").
|
||||
SetLLMService(option.DOUBAO_1_5_THINKING_VISION_PRO_250428),
|
||||
TestSteps: []hrp.IStep{
|
||||
hrp.NewStep("启动抖音「连了又连」小游戏").
|
||||
Android().
|
||||
StartToGoal("启动抖音,搜索「连了又连」小游戏,并启动游戏").
|
||||
Validate().
|
||||
AssertAI("当前位于抖音「连了又连」小游戏页面"),
|
||||
hrp.NewStep("开始游戏").
|
||||
Android().
|
||||
StartToGoal(userInstruction, option.WithMaxRetryTimes(100)),
|
||||
},
|
||||
}
|
||||
err := testCase.Dump2JSON("game_llk.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
// err = hrp.NewRunner(t).Run(testCase)
|
||||
// assert.Nil(t, err)
|
||||
}
|
||||
|
||||
// convertToGameElementFromQueryResult converts AI query result to GameElement for testing
|
||||
func convertToGameElementFromQueryResult(result *ai.QueryResult) (*GameElement, error) {
|
||||
if result == nil {
|
||||
|
||||
@@ -6,16 +6,9 @@
|
||||
},
|
||||
"ios": [
|
||||
{
|
||||
"perf_options": {
|
||||
"sys_cpu": true,
|
||||
"sys_mem": true,
|
||||
"fps": true,
|
||||
"network": true
|
||||
},
|
||||
"port": 8700,
|
||||
"mjpeg_port": 8800,
|
||||
"log_on": true,
|
||||
"xctest_bundle_id": "com.gtf.wda.runner.xctrunner"
|
||||
"log_on": true
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -23,6 +16,7 @@
|
||||
{
|
||||
"name": "启动抖音",
|
||||
"ios": {
|
||||
"os_type": "ios",
|
||||
"actions": [
|
||||
{
|
||||
"method": "home"
|
||||
@@ -34,8 +28,10 @@
|
||||
{
|
||||
"method": "swipe_to_tap_app",
|
||||
"params": "$app_name",
|
||||
"identifier": "启动抖音",
|
||||
"max_retry_times": 5
|
||||
"options": {
|
||||
"identifier": "启动抖音",
|
||||
"max_retry_times": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sleep",
|
||||
@@ -55,11 +51,14 @@
|
||||
{
|
||||
"name": "处理青少年弹窗",
|
||||
"ios": {
|
||||
"os_type": "ios",
|
||||
"actions": [
|
||||
{
|
||||
"method": "tap_ocr",
|
||||
"params": "我知道了",
|
||||
"ignore_NotFoundError": true
|
||||
"options": {
|
||||
"ignore_NotFoundError": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -67,11 +66,14 @@
|
||||
{
|
||||
"name": "进入购物页",
|
||||
"ios": {
|
||||
"os_type": "ios",
|
||||
"actions": [
|
||||
{
|
||||
"method": "tap_ocr",
|
||||
"params": "商城",
|
||||
"identifier": "点击商城"
|
||||
"options": {
|
||||
"identifier": "点击商城"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sleep",
|
||||
@@ -83,11 +85,14 @@
|
||||
{
|
||||
"name": "进入推荐页",
|
||||
"ios": {
|
||||
"os_type": "ios",
|
||||
"actions": [
|
||||
{
|
||||
"method": "tap_ocr",
|
||||
"params": "推荐",
|
||||
"identifier": "点击推荐"
|
||||
"options": {
|
||||
"identifier": "点击推荐"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sleep",
|
||||
@@ -99,29 +104,36 @@
|
||||
{
|
||||
"name": "向上滑动 2 次",
|
||||
"ios": {
|
||||
"os_type": "ios",
|
||||
"actions": [
|
||||
{
|
||||
"method": "swipe",
|
||||
"method": "swipe_direction",
|
||||
"params": "up",
|
||||
"identifier": "第 1 次上划"
|
||||
"options": {
|
||||
"identifier": "第 1 次上划"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sleep",
|
||||
"params": 2
|
||||
},
|
||||
{
|
||||
"method": "swipe",
|
||||
"method": "swipe_direction",
|
||||
"params": "up",
|
||||
"identifier": "第 2 次上划"
|
||||
"options": {
|
||||
"identifier": "第 2 次上划"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sleep",
|
||||
"params": 2
|
||||
},
|
||||
{
|
||||
"method": "swipe",
|
||||
"method": "swipe_direction",
|
||||
"params": "up",
|
||||
"identifier": "第 3 次上划"
|
||||
"options": {
|
||||
"identifier": "第 3 次上划"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sleep",
|
||||
@@ -133,7 +145,9 @@
|
||||
0.9,
|
||||
0.1
|
||||
],
|
||||
"identifier": "点击进入搜索框"
|
||||
"options": {
|
||||
"identifier": "点击进入搜索框"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "sleep",
|
||||
@@ -141,8 +155,17 @@
|
||||
},
|
||||
{
|
||||
"method": "input",
|
||||
"params": "httprunner",
|
||||
"identifier": "输入搜索关键词"
|
||||
"params": "httprunner 发版记录",
|
||||
"options": {
|
||||
"identifier": "输入搜索关键词"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "tap_ocr",
|
||||
"params": "搜索",
|
||||
"options": {
|
||||
"identifier": "点击搜索"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ func TestWDALog(t *testing.T) {
|
||||
SwipeUp(option.WithIdentifier("第 2 次上划")).Sleep(2).
|
||||
SwipeUp(option.WithIdentifier("第 3 次上划")).Sleep(2).
|
||||
TapXY(0.9, 0.1, option.WithIdentifier("点击进入搜索框")).Sleep(2).
|
||||
Input("httprunner", option.WithIdentifier("输入搜索关键词")),
|
||||
Input("httprunner 发版记录", option.WithIdentifier("输入搜索关键词")).
|
||||
TapByOCR("搜索", option.WithIdentifier("点击搜索")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
v5.0.0-beta-2506261443
|
||||
v5.0.0-250627
|
||||
|
||||
34
runner.go
34
runner.go
@@ -18,12 +18,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/httprunner/funplugin"
|
||||
"github.com/jinzhu/copier"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/net/http2"
|
||||
|
||||
"github.com/httprunner/funplugin"
|
||||
"github.com/httprunner/httprunner/v5/code"
|
||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
||||
"github.com/httprunner/httprunner/v5/internal/config"
|
||||
@@ -50,10 +50,11 @@ func NewRunner(t *testing.T) *HRPRunner {
|
||||
interruptSignal := make(chan os.Signal, 1)
|
||||
signal.Notify(interruptSignal, syscall.SIGTERM, syscall.SIGINT)
|
||||
return &HRPRunner{
|
||||
t: t,
|
||||
failfast: true, // default to failfast
|
||||
genHTMLReport: false,
|
||||
mcpConfigPath: "",
|
||||
t: t,
|
||||
failfast: true, // default to failfast
|
||||
genHTMLReport: false,
|
||||
mcpConfigPath: "",
|
||||
autoPopupHandler: false,
|
||||
httpClient: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
@@ -87,6 +88,7 @@ type HRPRunner struct {
|
||||
saveTests bool
|
||||
genHTMLReport bool
|
||||
mcpConfigPath string // MCP config file path
|
||||
autoPopupHandler bool // enable auto popup handler for all UI steps
|
||||
httpClient *http.Client
|
||||
http2Client *http.Client
|
||||
wsDialer *websocket.Dialer
|
||||
@@ -191,8 +193,17 @@ func (r *HRPRunner) SetSaveTests(saveTests bool) *HRPRunner {
|
||||
|
||||
// GenHTMLReport configures whether to gen html report of api tests.
|
||||
func (r *HRPRunner) GenHTMLReport() *HRPRunner {
|
||||
log.Info().Bool("genHTMLReport", true).Msg("[init] SetgenHTMLReport")
|
||||
log.Info().Bool("genHTMLReport", true).Bool("saveTests", true).
|
||||
Msg("[init] SetGenHTMLReport")
|
||||
r.genHTMLReport = true
|
||||
r.saveTests = true
|
||||
return r
|
||||
}
|
||||
|
||||
// EnableAutoPopupHandler configures whether to enable auto popup handler for all UI steps.
|
||||
func (r *HRPRunner) EnableAutoPopupHandler(enabled bool) *HRPRunner {
|
||||
log.Info().Bool("autoPopupHandler", enabled).Msg("[init] EnableAutoPopupHandler")
|
||||
r.autoPopupHandler = enabled
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -392,6 +403,13 @@ func NewCaseRunner(testcase TestCase, hrpRunner *HRPRunner) (*CaseRunner, error)
|
||||
return nil, errors.Wrap(err, "parse testcase config failed")
|
||||
}
|
||||
|
||||
// apply global auto popup handler setting if enabled
|
||||
// priority: command line > testcase config > step config
|
||||
if hrpRunner.autoPopupHandler {
|
||||
parsedConfig.AutoPopupHandler = true
|
||||
log.Info().Bool("autoPopupHandler", true).Msg("applied global auto popup handler setting")
|
||||
}
|
||||
|
||||
// set request timeout in seconds
|
||||
if parsedConfig.RequestTimeout != 0 {
|
||||
hrpRunner.SetRequestTimeout(parsedConfig.RequestTimeout)
|
||||
@@ -855,8 +873,6 @@ func (r *SessionRunner) RunStep(step IStep) (stepResult *StepResult, err error)
|
||||
stepName := step.Name()
|
||||
stepType := string(step.Type())
|
||||
|
||||
log.Info().Str("step", stepName).Str("type", stepType).Msg(RUN_STEP_START)
|
||||
|
||||
// execute step with parameters iterator
|
||||
tasks, err := r.generateExecutionTasks(step)
|
||||
if err != nil {
|
||||
@@ -897,6 +913,8 @@ func (r *SessionRunner) RunStep(step IStep) (stepResult *StepResult, err error)
|
||||
|
||||
// execute with loops as outer iteration
|
||||
for _, task := range tasks {
|
||||
log.Info().Str("step", task.stepName).Str("type", stepType).Msg(RUN_STEP_START)
|
||||
|
||||
// Check for interrupt signal before each parameter iteration
|
||||
select {
|
||||
case <-r.caseRunner.hrpRunner.interruptSignal:
|
||||
|
||||
@@ -11,7 +11,7 @@ PRE_COMMIT_FILE=.git/hooks/pre-commit
|
||||
# install pre-commit hook and make it executable
|
||||
function install() {
|
||||
go get mvdan.cc/gofumpt
|
||||
go get github.com/incu6us/goimports-reviser/v2@latest
|
||||
go get github.com/incu6us/goimports-reviser/v3@latest
|
||||
cat > $PRE_COMMIT_FILE <<'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
@@ -45,7 +45,7 @@ done
|
||||
version_file=internal/version/VERSION
|
||||
|
||||
# get current date
|
||||
current_date=$(date +"%y%m%d%H%M")
|
||||
current_date=$(date +"%y%m%d")
|
||||
|
||||
# update version
|
||||
sed -i '' "s/[0-9]\{10\}/${current_date}/" "$version_file"
|
||||
|
||||
@@ -765,6 +765,10 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
|
||||
identifier = action.Identifier
|
||||
break
|
||||
}
|
||||
if action.Options != nil && action.Options.Identifier != "" {
|
||||
identifier = action.Options.Identifier
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
stepResult.Identifier = identifier
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
hrp "github.com/httprunner/httprunner/v5"
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// GameInfo 定义游戏界面分析的输出格式
|
||||
@@ -111,79 +110,6 @@ func TestAndroidAction(t *testing.T) {
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestGameLianliankan(t *testing.T) {
|
||||
userInstruction := `连连看是一款经典的益智消除类小游戏,通常以图案或图标为主要元素。以下是连连看的基本规则说明:
|
||||
1. 游戏目标: 玩家需要通过连接相同的图案或图标,将它们从游戏界面中消除。
|
||||
2. 连接规则:
|
||||
- 两个相同的图案可以通过不超过三条直线连接。
|
||||
- 连接线可以水平或垂直,但不能斜线,也不能跨过其他图案。
|
||||
- 连接线的转折次数不能超过两次。
|
||||
3. 游戏界面:
|
||||
- 游戏界面是一个矩形区域,内含多个图案或图标,排列成行和列;图案或图标在未选中状态下背景为白色,选中状态下背景为绿色。
|
||||
- 游戏界面下方是道具区域,共有 3 种道具,从左到右分别是:「高亮显示」、「随机打乱」、「减少种类」。
|
||||
4、游戏攻略:建议多次使用道具,可以降低游戏难度
|
||||
- 优先使用「减少种类」道具,可以将图案种类随机减少一种
|
||||
- 遇到困难时,推荐使用「随机打乱」道具,可以获得很多新的消除机会
|
||||
- 观看广告视频,待屏幕右上角出现「领取成功」后,点击其右侧的 X 即可关闭广告,继续游戏
|
||||
|
||||
请严格按照以上游戏规则,开始游戏
|
||||
`
|
||||
|
||||
testCase := &hrp.TestCase{
|
||||
Config: hrp.NewConfig("连连看小游戏自动化测试").
|
||||
SetLLMService(option.DOUBAO_1_5_THINKING_VISION_PRO_250428),
|
||||
TestSteps: []hrp.IStep{
|
||||
hrp.NewStep("启动抖音「连了又连」小游戏").
|
||||
Android().
|
||||
StartToGoal("启动抖音,搜索「连了又连」小游戏,并启动游戏").
|
||||
Validate().
|
||||
AssertAI("当前位于抖音「连了又连」小游戏页面"),
|
||||
hrp.NewStep("开始游戏").
|
||||
Android().
|
||||
StartToGoal(userInstruction, option.WithMaxRetryTimes(100)),
|
||||
},
|
||||
}
|
||||
err := testCase.Dump2JSON("game_llk.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
err = hrp.NewRunner(t).Run(testCase)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestGame2048(t *testing.T) {
|
||||
userInstruction := `2048 是一款数字合并类的益智小游戏,以下是它的基本规则:
|
||||
1、游戏目标:在一个 4x4 的网格中,通过合并相同数字的方块,最终得到一个数值为 2048 的方块。当然,若你能继续合并,也可追求更高的数字。
|
||||
2、初始状态:游戏开始时,网格中会随机出现两个数字为 2 或 4 的方块。
|
||||
3、移动操作:玩家可以选择上、下、左、右四个方向进行移动。每次移动时,所有方块会朝着指定方向滑动,直到碰到边界或其他方块。
|
||||
4、合并规则:当两个相同数字的方块在移动过程中相遇时,它们会合并成一个新的方块,新方块的数值为原来两个方块数值之和。例如,两个 2 合并成一个 4,两个 4 合并成一个 8,依此类推。
|
||||
5、新方块生成:每次移动结束后,网格中会随机出现一个新的数字为 2 或 4 的方块。
|
||||
6、注意事项:若连续多次滑动无法生效,请调整策略;例如,向上无法滑动,可以尝试向下滑;向左无法滑动,可以尝试向右滑。
|
||||
7、游戏结束:当网格被填满,且没有可合并的方块时,游戏结束,停止游戏。
|
||||
|
||||
请严格按照以上游戏规则,开始游戏
|
||||
`
|
||||
|
||||
testCase := &hrp.TestCase{
|
||||
Config: hrp.NewConfig("2048 小游戏自动化测试").
|
||||
SetLLMService(option.DOUBAO_1_5_UI_TARS_250328),
|
||||
TestSteps: []hrp.IStep{
|
||||
hrp.NewStep("启动抖音「2048经典」小游戏").
|
||||
Android().
|
||||
StartToGoal("启动抖音,搜索「2048经典」小游戏,并启动游戏").
|
||||
Validate().
|
||||
AssertAI("当前位于抖音「2048」小游戏页面"),
|
||||
hrp.NewStep("开始游戏").
|
||||
Android().
|
||||
StartToGoal(userInstruction, option.WithMaxRetryTimes(100)),
|
||||
},
|
||||
}
|
||||
err := testCase.Dump2JSON("game_2048.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
// err = hrp.NewRunner(t).Run(testCase)
|
||||
// assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestAIAction(t *testing.T) {
|
||||
testCase := &hrp.TestCase{
|
||||
Config: hrp.NewConfig("run ui action with ai").
|
||||
|
||||
@@ -135,9 +135,17 @@ func (p *Planner) Plan(ctx context.Context, opts *PlanningOptions) (result *Plan
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
toolCallID += toolCall.ID
|
||||
}
|
||||
|
||||
// Ensure content is not empty for Tool messages to avoid API 400 errors
|
||||
// Some models may return empty content when using function calling
|
||||
toolContent := message.Content
|
||||
if toolContent == "" {
|
||||
toolContent = "Function call initiated"
|
||||
}
|
||||
|
||||
p.history.Append(&schema.Message{
|
||||
Role: schema.Tool,
|
||||
Content: message.Content,
|
||||
Content: toolContent,
|
||||
ToolCalls: message.ToolCalls,
|
||||
ToolCallID: toolCallID,
|
||||
})
|
||||
@@ -158,7 +166,7 @@ func (p *Planner) Plan(ctx context.Context, opts *PlanningOptions) (result *Plan
|
||||
Error: err.Error(),
|
||||
ModelName: string(p.modelConfig.ModelType),
|
||||
}
|
||||
log.Debug().Str("reason", err.Error()).Msg("parse content to actions failed")
|
||||
log.Warn().Str("reason", err.Error()).Msg("parse content to actions failed")
|
||||
}
|
||||
// append assistant message (since we're parsing content, not using native function calling)
|
||||
p.history.Append(&schema.Message{
|
||||
|
||||
@@ -731,11 +731,21 @@ func (ad *ADBDriver) ForegroundInfo() (app types.AppInfo, err error) {
|
||||
if err != nil {
|
||||
return app, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(strings.TrimSpace(packageInfo)), &app)
|
||||
|
||||
// Clean packageInfo: remove null bytes that cause JSON parsing issues
|
||||
packageInfo = strings.ReplaceAll(packageInfo, "\x00", "")
|
||||
|
||||
// Check for empty response after cleaning
|
||||
if strings.TrimSpace(packageInfo) == "" {
|
||||
return app, errors.New("empty response from evalite process")
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(packageInfo), &app)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("packageInfo", packageInfo).Msg("get foreground app failed")
|
||||
return app, err
|
||||
}
|
||||
return
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (ad *ADBDriver) SetIme(imeRegx string) error {
|
||||
|
||||
@@ -424,6 +424,9 @@ func resizeImage(src image.Image, width, height int) image.Image {
|
||||
|
||||
// CompressImageFile compresses an image file and returns the compressed data
|
||||
func CompressImageFile(imagePath string, enableResize bool, maxWidth int) ([]byte, error) {
|
||||
log.Debug().Str("imagePath", imagePath).Bool("enableResize", enableResize).
|
||||
Int("maxWidth", maxWidth).Msg("compress image file")
|
||||
|
||||
// Read the original image file
|
||||
file, err := os.Open(imagePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -87,6 +87,7 @@ func (s *DriverSession) GetData(withReset bool) SessionData {
|
||||
}
|
||||
|
||||
func (s *DriverSession) SetBaseURL(baseUrl string) {
|
||||
log.Info().Str("baseUrl", baseUrl).Msg("set driver session base URL")
|
||||
s.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
@@ -157,25 +158,42 @@ func (s *DriverSession) DELETE(urlStr string) (rawResp DriverRawResponse, err er
|
||||
|
||||
func (s *DriverSession) RequestWithRetry(method string, urlStr string, rawBody []byte) (
|
||||
rawResp DriverRawResponse, err error) {
|
||||
for count := 1; count <= s.maxRetry; count++ {
|
||||
var lastError error
|
||||
|
||||
for attempt := 1; attempt <= s.maxRetry; attempt++ {
|
||||
// Execute the request
|
||||
rawResp, err = s.Request(method, urlStr, rawBody)
|
||||
if err == nil {
|
||||
return
|
||||
if attempt > 1 {
|
||||
log.Info().Msgf("request succeeded after %d attempts", attempt)
|
||||
}
|
||||
return rawResp, nil
|
||||
}
|
||||
|
||||
lastError = err
|
||||
log.Warn().Err(err).Msgf("request failed, attempt %d/%d", attempt, s.maxRetry)
|
||||
|
||||
// If this was the last attempt, break
|
||||
if attempt == s.maxRetry {
|
||||
log.Error().Err(lastError).Msgf("all %d retry attempts failed, giving up", s.maxRetry)
|
||||
break
|
||||
}
|
||||
|
||||
// Wait before next attempt
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Try to reset the session for the next attempt
|
||||
if s.resetFn != nil {
|
||||
log.Warn().Msg("reset driver session")
|
||||
if err2 := s.resetFn(); err2 != nil {
|
||||
log.Error().Err(err2).Msgf(
|
||||
"failed to reset session, try count %v", count)
|
||||
log.Warn().Msgf("attempting to reset driver session before attempt %d", attempt+1)
|
||||
if resetErr := s.resetFn(); resetErr != nil {
|
||||
log.Error().Err(resetErr).Msgf("failed to reset session, will retry without reset")
|
||||
} else {
|
||||
log.Info().Msgf(
|
||||
"reset session success, try count %v", count)
|
||||
log.Info().Msg("session reset successful")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
return nil, lastError
|
||||
}
|
||||
|
||||
func (s *DriverSession) Request(method string, urlStr string, rawBody []byte) (
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
package uixt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
@@ -25,7 +24,7 @@ func TestWindowSize(t *testing.T) {
|
||||
driver := setupHDCDriverExt(t)
|
||||
size, err := driver.WindowSize()
|
||||
assert.Nil(t, err)
|
||||
t.Log(fmt.Sprintf("width: %d, height: %d", size.Width, size.Height))
|
||||
assert.NotNil(t, size)
|
||||
}
|
||||
|
||||
func TestHarmonyTap(t *testing.T) {
|
||||
|
||||
@@ -164,6 +164,9 @@ func (dev *IOSDevice) Setup() error {
|
||||
dev.DeviceEntry.UserspaceTUNPort = info.UserspaceTUNPort
|
||||
dev.DeviceEntry.UserspaceTUN = info.UserspaceTUN
|
||||
rsdService, err := ios.NewWithAddrPortDevice(info.Address, info.RsdPort, dev.DeviceEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rsdService.Close()
|
||||
rsdProvider, err := rsdService.Handshake()
|
||||
if err != nil {
|
||||
@@ -218,14 +221,6 @@ func (dev *IOSDevice) NewDriver() (driver IDriver, err error) {
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to init WDA driver")
|
||||
}
|
||||
settings, err := wdaDriver.SetAppiumSettings(map[string]interface{}{
|
||||
"snapshotMaxDepth": dev.Options.SnapshotMaxDepth,
|
||||
"acceptAlertButtonSelector": dev.Options.AcceptAlertButtonSelector,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to set appium WDA settings")
|
||||
}
|
||||
log.Info().Interface("appiumWDASettings", settings).Msg("set appium WDA settings")
|
||||
|
||||
if dev.Options.ResetHomeOnStartup {
|
||||
log.Info().Msg("go back to home screen")
|
||||
|
||||
@@ -36,13 +36,19 @@ func NewWDADriver(device *IOSDevice) (*WDADriver, error) {
|
||||
Session: NewDriverSession(),
|
||||
}
|
||||
|
||||
if !device.Options.LazySetup {
|
||||
// setup driver
|
||||
if err := driver.Setup(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// setup driver
|
||||
if err := driver.Setup(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// check WDA status
|
||||
wdaStatus, err := driver.Status()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Info().Interface("status", wdaStatus).
|
||||
Msg("check WDA status")
|
||||
|
||||
// register driver session reset handler
|
||||
driver.Session.RegisterResetHandler(driver.Setup)
|
||||
|
||||
@@ -665,7 +671,7 @@ func (wd *WDADriver) Input(text string, opts ...option.ActionOption) (err error)
|
||||
// [[FBRoute POST:@"/wda/keys"] respondWithTarget:self action:@selector(handleKeys:)]
|
||||
data := map[string]interface{}{"value": strings.Split(text, "")}
|
||||
option.MergeOptions(data, opts...)
|
||||
_, err = wd.Session.POST(data, "/gtf/interaction/input")
|
||||
_, err = wd.Session.POST(data, "/wings/interaction/keys")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ func TestDevice_IOS_Install(t *testing.T) {
|
||||
func TestDriver_WDA_LazySetup(t *testing.T) {
|
||||
device, err := NewIOSDevice(
|
||||
option.WithWDAPort(8700),
|
||||
option.WithWDAMjpegPort(8800),
|
||||
option.WithLazySetup(true))
|
||||
option.WithWDAMjpegPort(8800))
|
||||
require.Nil(t, err)
|
||||
driver, err := NewWDADriver(device)
|
||||
require.Nil(t, err)
|
||||
|
||||
@@ -193,6 +193,8 @@ func extractActionOptionsToArguments(actionOptions []option.ActionOption, argume
|
||||
"tap_random_rect": tempOptions.TapRandomRect,
|
||||
"anti_risk": tempOptions.AntiRisk,
|
||||
"pre_mark_operation": tempOptions.PreMarkOperation,
|
||||
"reset_history": tempOptions.ResetHistory,
|
||||
"match_one": tempOptions.MatchOne,
|
||||
}
|
||||
|
||||
// Add boolean options only if they are true
|
||||
@@ -209,6 +211,18 @@ func extractActionOptionsToArguments(actionOptions []option.ActionOption, argume
|
||||
if tempOptions.Index != 0 {
|
||||
arguments["index"] = tempOptions.Index
|
||||
}
|
||||
if tempOptions.Interval > 0 {
|
||||
arguments["interval"] = tempOptions.Interval
|
||||
}
|
||||
if tempOptions.Steps > 0 {
|
||||
arguments["steps"] = tempOptions.Steps
|
||||
}
|
||||
if tempOptions.Timeout > 0 {
|
||||
arguments["timeout"] = tempOptions.Timeout
|
||||
}
|
||||
if tempOptions.Frequency > 0 {
|
||||
arguments["frequency"] = tempOptions.Frequency
|
||||
}
|
||||
// Only set duration if it's not already set (to avoid overriding tool-specific conversions)
|
||||
if tempOptions.Duration > 0 {
|
||||
if _, exists := arguments["duration"]; !exists {
|
||||
@@ -288,13 +302,19 @@ func extractActionOptionsToArguments(actionOptions []option.ActionOption, argume
|
||||
if tempOptions.Selector != "" {
|
||||
arguments["selector"] = tempOptions.Selector
|
||||
}
|
||||
}
|
||||
|
||||
func getFloat64ValueOrDefault(value float64, defaultValue float64) float64 {
|
||||
if value == 0 {
|
||||
return defaultValue
|
||||
if tempOptions.Identifier != "" {
|
||||
arguments["identifier"] = tempOptions.Identifier
|
||||
}
|
||||
|
||||
// Add direction option (can be string or []float64)
|
||||
if tempOptions.Direction != nil {
|
||||
arguments["direction"] = tempOptions.Direction
|
||||
}
|
||||
|
||||
// Add custom options
|
||||
if len(tempOptions.Custom) > 0 {
|
||||
arguments["custom"] = tempOptions.Custom
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// parseActionOptions converts MCP request arguments to ActionOptions struct
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
)
|
||||
|
||||
// ToolStartToGoal implements the start_to_goal tool call.
|
||||
@@ -162,7 +163,7 @@ func (t *ToolAIQuery) Implement() server.ToolHandlerFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build action options from unified request
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// AI query logic with options
|
||||
|
||||
@@ -5,11 +5,12 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
)
|
||||
|
||||
// ToolSwipe implements the generic swipe tool call.
|
||||
@@ -124,15 +125,13 @@ func (t *ToolSwipeDirection) Implement() server.ToolHandlerFunc {
|
||||
swipeDirection, validDirections)
|
||||
}
|
||||
|
||||
opts := []option.ActionOption{
|
||||
option.WithDuration(getFloat64ValueOrDefault(unifiedReq.Duration, 0.5)),
|
||||
option.WithPressDuration(getFloat64ValueOrDefault(unifiedReq.PressDuration, 0.1)),
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
if unifiedReq.Duration == 0 {
|
||||
opts = append(opts, option.WithDuration(0.5))
|
||||
}
|
||||
if unifiedReq.AntiRisk {
|
||||
opts = append(opts, option.WithAntiRisk(true))
|
||||
}
|
||||
if unifiedReq.PreMarkOperation {
|
||||
opts = append(opts, option.WithPreMarkOperation(true))
|
||||
if unifiedReq.PressDuration == 0 {
|
||||
opts = append(opts, option.WithPressDuration(0.1))
|
||||
}
|
||||
|
||||
// Convert direction to coordinates and perform swipe
|
||||
@@ -240,17 +239,8 @@ func (t *ToolSwipeCoordinate) Implement() server.ToolHandlerFunc {
|
||||
|
||||
params := []float64{unifiedReq.FromX, unifiedReq.FromY, unifiedReq.ToX, unifiedReq.ToY}
|
||||
|
||||
// Build action options from the unified request
|
||||
opts := []option.ActionOption{}
|
||||
if unifiedReq.Duration > 0 {
|
||||
opts = append(opts, option.WithDuration(unifiedReq.Duration))
|
||||
}
|
||||
if unifiedReq.PressDuration > 0 {
|
||||
opts = append(opts, option.WithPressDuration(unifiedReq.PressDuration))
|
||||
}
|
||||
if unifiedReq.AntiRisk {
|
||||
opts = append(opts, option.WithAntiRisk(true))
|
||||
}
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
swipeAction := prepareSwipeAction(driverExt, params, opts...)
|
||||
err = swipeAction(driverExt)
|
||||
@@ -327,7 +317,7 @@ func (t *ToolSwipeToTapApp) Implement() server.ToolHandlerFunc {
|
||||
}
|
||||
|
||||
// Build action options from request structure
|
||||
var opts []option.ActionOption
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// Add boolean options
|
||||
if unifiedReq.IgnoreNotFoundError {
|
||||
@@ -400,24 +390,8 @@ func (t *ToolSwipeToTapText) Implement() server.ToolHandlerFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build action options from request structure
|
||||
var opts []option.ActionOption
|
||||
|
||||
// Add boolean options
|
||||
if unifiedReq.IgnoreNotFoundError {
|
||||
opts = append(opts, option.WithIgnoreNotFoundError(true))
|
||||
}
|
||||
if unifiedReq.Regex {
|
||||
opts = append(opts, option.WithRegex(true))
|
||||
}
|
||||
|
||||
// Add numeric options
|
||||
if unifiedReq.MaxRetryTimes > 0 {
|
||||
opts = append(opts, option.WithMaxRetryTimes(unifiedReq.MaxRetryTimes))
|
||||
}
|
||||
if unifiedReq.Index > 0 {
|
||||
opts = append(opts, option.WithIndex(unifiedReq.Index))
|
||||
}
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// Swipe to tap text action logic
|
||||
err = driverExt.SwipeToTapTexts([]string{unifiedReq.Text}, opts...)
|
||||
@@ -478,24 +452,8 @@ func (t *ToolSwipeToTapTexts) Implement() server.ToolHandlerFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build action options from request structure
|
||||
var opts []option.ActionOption
|
||||
|
||||
// Add boolean options
|
||||
if unifiedReq.IgnoreNotFoundError {
|
||||
opts = append(opts, option.WithIgnoreNotFoundError(true))
|
||||
}
|
||||
if unifiedReq.Regex {
|
||||
opts = append(opts, option.WithRegex(true))
|
||||
}
|
||||
|
||||
// Add numeric options
|
||||
if unifiedReq.MaxRetryTimes > 0 {
|
||||
opts = append(opts, option.WithMaxRetryTimes(unifiedReq.MaxRetryTimes))
|
||||
}
|
||||
if unifiedReq.Index > 0 {
|
||||
opts = append(opts, option.WithIndex(unifiedReq.Index))
|
||||
}
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// Swipe to tap texts action logic
|
||||
err = driverExt.SwipeToTapTexts(unifiedReq.Texts, opts...)
|
||||
@@ -575,12 +533,10 @@ func (t *ToolDrag) Implement() server.ToolHandlerFunc {
|
||||
return nil, fmt.Errorf("from_x, from_y, to_x, and to_y coordinates are required")
|
||||
}
|
||||
|
||||
opts := []option.ActionOption{}
|
||||
if unifiedReq.Duration > 0 {
|
||||
opts = append(opts, option.WithDuration(unifiedReq.Duration/1000.0))
|
||||
}
|
||||
if unifiedReq.AntiRisk {
|
||||
opts = append(opts, option.WithAntiRisk(true))
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
if unifiedReq.Duration == 0 {
|
||||
opts = append(opts, option.WithDuration(0.5))
|
||||
}
|
||||
|
||||
// Drag action logic
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
)
|
||||
|
||||
// ToolTapXY implements the tap_xy tool call.
|
||||
@@ -42,14 +43,9 @@ func (t *ToolTapXY) Implement() server.ToolHandlerFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get options directly since ActionOptions is now ActionOptions
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// Add configurable options based on request
|
||||
if unifiedReq.PreMarkOperation {
|
||||
opts = append(opts, option.WithPreMarkOperation(true))
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if unifiedReq.X == 0 || unifiedReq.Y == 0 {
|
||||
return nil, fmt.Errorf("x and y coordinates are required")
|
||||
@@ -123,19 +119,9 @@ func (t *ToolTapAbsXY) Implement() server.ToolHandlerFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get options directly since ActionOptions is now ActionOptions
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// Add configurable options based on request
|
||||
if unifiedReq.PreMarkOperation {
|
||||
opts = append(opts, option.WithPreMarkOperation(true))
|
||||
}
|
||||
|
||||
// Add AntiRisk support
|
||||
if unifiedReq.AntiRisk {
|
||||
opts = append(opts, option.WithAntiRisk(true))
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if unifiedReq.X == 0 || unifiedReq.Y == 0 {
|
||||
return nil, fmt.Errorf("x and y coordinates are required")
|
||||
@@ -208,14 +194,9 @@ func (t *ToolTapByOCR) Implement() server.ToolHandlerFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get options directly since ActionOptions is now ActionOptions
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// Add configurable options based on request
|
||||
if unifiedReq.PreMarkOperation {
|
||||
opts = append(opts, option.WithPreMarkOperation(true))
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if unifiedReq.Text == "" {
|
||||
return nil, fmt.Errorf("text parameter is required")
|
||||
@@ -277,14 +258,9 @@ func (t *ToolTapByCV) Implement() server.ToolHandlerFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get options directly since ActionOptions is now ActionOptions
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// Add configurable options based on request
|
||||
if unifiedReq.PreMarkOperation {
|
||||
opts = append(opts, option.WithPreMarkOperation(true))
|
||||
}
|
||||
|
||||
// For TapByCV, we need to check if there are UI types in the options
|
||||
// In the original DoAction, it requires ScreenShotWithUITypes to be set
|
||||
// We'll add a basic implementation that triggers CV recognition
|
||||
|
||||
@@ -5,11 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
)
|
||||
|
||||
// ToolWebLoginNoneUI implements the web_login_none_ui tool call.
|
||||
@@ -170,7 +171,7 @@ func (t *ToolHoverBySelector) Implement() server.ToolHandlerFunc {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Get options directly since ActionOptions is now ActionOptions
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// Hover by selector action logic
|
||||
@@ -228,7 +229,7 @@ func (t *ToolTapBySelector) Implement() server.ToolHandlerFunc {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Get options directly since ActionOptions is now ActionOptions
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// Tap by selector action logic
|
||||
@@ -286,7 +287,7 @@ func (t *ToolSecondaryClickBySelector) Implement() server.ToolHandlerFunc {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Get options directly since ActionOptions is now ActionOptions
|
||||
// Build all options from request arguments
|
||||
opts := unifiedReq.Options()
|
||||
|
||||
// Secondary click by selector action logic
|
||||
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
||||
"github.com/httprunner/httprunner/v5/uixt/types"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/internal/builtin"
|
||||
"github.com/httprunner/httprunner/v5/uixt/types"
|
||||
)
|
||||
|
||||
type MobileAction struct {
|
||||
@@ -326,6 +327,10 @@ func (o *ActionOptions) Options() []ActionOption {
|
||||
options = append(options, WithAntiRisk(true))
|
||||
}
|
||||
|
||||
if o.PreMarkOperation {
|
||||
options = append(options, WithPreMarkOperation(true))
|
||||
}
|
||||
|
||||
// custom options
|
||||
if o.Custom != nil {
|
||||
for k, v := range o.Custom {
|
||||
|
||||
@@ -152,17 +152,6 @@ func WithDeviceWDAMjpegPort(port int) DeviceOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithDeviceLazySetup(lazySetup bool) DeviceOption {
|
||||
return func(device *DeviceOptions) {
|
||||
if device.IOSDeviceOptions != nil {
|
||||
device.IOSDeviceOptions.LazySetup = lazySetup
|
||||
}
|
||||
if device.Platform == "" {
|
||||
device.Platform = "ios"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithDeviceResetHomeOnStartup(reset bool) DeviceOption {
|
||||
return func(device *DeviceOptions) {
|
||||
if device.IOSDeviceOptions != nil {
|
||||
|
||||
@@ -6,7 +6,6 @@ type IOSDeviceOptions struct {
|
||||
WDAPort int `json:"port,omitempty" yaml:"port,omitempty"` // WDA remote port
|
||||
WDAMjpegPort int `json:"mjpeg_port,omitempty" yaml:"mjpeg_port,omitempty"` // WDA remote MJPEG port
|
||||
LogOn bool `json:"log_on,omitempty" yaml:"log_on,omitempty"`
|
||||
LazySetup bool `json:"lazy_setup,omitempty" yaml:"lazy_setup,omitempty"` // lazy setup WDA
|
||||
|
||||
// switch to iOS springboard before init WDA session
|
||||
ResetHomeOnStartup bool `json:"reset_home_on_startup,omitempty" yaml:"reset_home_on_startup,omitempty"`
|
||||
@@ -33,9 +32,6 @@ func (dev *IOSDeviceOptions) Options() (deviceOptions []IOSDeviceOption) {
|
||||
if dev.LogOn {
|
||||
deviceOptions = append(deviceOptions, WithWDALogOn(true))
|
||||
}
|
||||
if dev.LazySetup {
|
||||
deviceOptions = append(deviceOptions, WithLazySetup(true))
|
||||
}
|
||||
if dev.ResetHomeOnStartup {
|
||||
deviceOptions = append(deviceOptions, WithResetHomeOnStartup(true))
|
||||
}
|
||||
@@ -52,23 +48,8 @@ func (dev *IOSDeviceOptions) Options() (deviceOptions []IOSDeviceOption) {
|
||||
}
|
||||
|
||||
const (
|
||||
defaultWDAPort = 8100
|
||||
defaultMjpegPort = 9100
|
||||
)
|
||||
|
||||
const (
|
||||
// Changes the value of maximum depth for traversing elements source tree.
|
||||
// It may help to prevent out of memory or timeout errors while getting the elements source tree,
|
||||
// but it might restrict the depth of source tree.
|
||||
// A part of elements source tree might be lost if the value was too small. Defaults to 50
|
||||
defaultSnapshotMaxDepth = 10
|
||||
// Allows to customize accept/dismiss alert button selector.
|
||||
// It helps you to handle an arbitrary element as accept button in accept alert command.
|
||||
// The selector should be a valid class chain expression, where the search root is the alert element itself.
|
||||
// The default button location algorithm is used if the provided selector is wrong or does not match any element.
|
||||
// e.g. **/XCUIElementTypeButton[`label CONTAINS[c] 'accept'`]
|
||||
acceptAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'允许','好','仅在使用应用期间','稍后再说'}`]"
|
||||
dismissAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'不允许','暂不'}`]"
|
||||
defaultWDAPort = 8700
|
||||
defaultMjpegPort = 8800
|
||||
)
|
||||
|
||||
func NewIOSDeviceOptions(opts ...IOSDeviceOption) *IOSDeviceOptions {
|
||||
@@ -84,16 +65,6 @@ func NewIOSDeviceOptions(opts ...IOSDeviceOption) *IOSDeviceOptions {
|
||||
config.WDAMjpegPort = defaultMjpegPort
|
||||
}
|
||||
|
||||
if config.SnapshotMaxDepth == 0 {
|
||||
config.SnapshotMaxDepth = defaultSnapshotMaxDepth
|
||||
}
|
||||
if config.AcceptAlertButtonSelector == "" {
|
||||
config.AcceptAlertButtonSelector = acceptAlertButtonSelector
|
||||
}
|
||||
if config.DismissAlertButtonSelector == "" {
|
||||
config.DismissAlertButtonSelector = dismissAlertButtonSelector
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -129,12 +100,6 @@ func WithWDALogOn(logOn bool) IOSDeviceOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithLazySetup(lazySetup bool) IOSDeviceOption {
|
||||
return func(device *IOSDeviceOptions) {
|
||||
device.LazySetup = lazySetup
|
||||
}
|
||||
}
|
||||
|
||||
func WithResetHomeOnStartup(reset bool) IOSDeviceOption {
|
||||
return func(device *IOSDeviceOptions) {
|
||||
device.ResetHomeOnStartup = reset
|
||||
|
||||
@@ -147,6 +147,7 @@ func (dExt *XTDriver) ExecuteAction(ctx context.Context, action option.MobileAct
|
||||
sessionData := dExt.GetSession().GetData(true) // reset after getting data
|
||||
|
||||
log.Debug().Str("tool", string(tool.Name())).
|
||||
Interface("result", result.Content).
|
||||
Msg("executed action via MCP tool")
|
||||
return sessionData, nil
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import "fmt"
|
||||
type DeviceStatus struct {
|
||||
Message string `json:"message"`
|
||||
State string `json:"state"`
|
||||
Ready bool `json:"ready"`
|
||||
Device string `json:"device"`
|
||||
OS struct {
|
||||
TestmanagerdVersion int `json:"testmanagerdVersion"`
|
||||
Name string `json:"name"`
|
||||
@@ -39,10 +41,11 @@ type DeviceStatus struct {
|
||||
IP string `json:"ip"`
|
||||
SimulatorVersion string `json:"simulatorVersion"`
|
||||
} `json:"ios"`
|
||||
Ready bool `json:"ready"`
|
||||
Build struct {
|
||||
Time string `json:"time"`
|
||||
ProductBundleIdentifier string `json:"productBundleIdentifier"`
|
||||
Version string `json:"version"` // OpenSource WDA version
|
||||
GtfWDAVersion string `json:"gtfWDAVersion"` // GTF WDA version
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user