diff --git a/examples/game/romantic_restaurant/game_romantic_restaurant.json b/examples/game/romantic_restaurant/game_romantic_restaurant.json index 2a5e2654..98540547 100644 --- a/examples/game/romantic_restaurant/game_romantic_restaurant.json +++ b/examples/game/romantic_restaurant/game_romantic_restaurant.json @@ -9,38 +9,17 @@ } }, "teststeps": [ - { - "name": "启动抖音 app", - "android": { - "os_type": "android", - "actions": [ - { - "method": "app_launch", - "params": "$package_name" - }, - { - "method": "sleep", - "params": 5 - } - ] - }, - "validate": [ - { - "check": "ui_foreground_app", - "assert": "equal", - "expect": "$package_name", - "msg": "app [$package_name] should be in foreground" - } - ] - }, { "name": "进入「浪漫餐厅」小游戏", "android": { "os_type": "android", "actions": [ + { + "method": "home" + }, { "method": "start_to_goal", - "params": "搜索「浪漫餐厅」,进入小游戏", + "params": "在手机桌面点击「浪漫餐厅」启动小游戏,等待游戏加载完成", "options": { "pre_mark_operation": true } @@ -63,7 +42,7 @@ "actions": [ { "method": "start_to_goal", - "params": "浪漫餐厅是一款经营类游戏,以下是游戏的基本规则说明:\n1、点击右下角锅铲,开始任务\n2、将棋子拖拽至相同棋子,可升级生成新棋子\n3、拖拽相同棋子时,被部分遮挡的棋子只能作为拖拽终点,不能作为拖拽起点\n4、当游戏界面中没有相同棋子时,可以点击游戏页面中央的购物袋生成新的棋子\n5、若不知道如何操作,请按照游戏指引进行游玩\n\n请严格按照以上游戏规则,开始游戏\n", + "params": "浪漫餐厅是一款经营类游戏,以下是游戏的基本规则说明:\n1、点击右下角锅铲,开始任务\n2、将棋子拖拽至相同棋子,可升级生成新棋子;注意,必须是相同类别和形状的棋子才能合成,例如,长面包和圆面包不能合成,方形蛋糕和三角形蛋糕不能合成\n3、拖拽相同棋子时,被部分遮挡的棋子只能作为拖拽终点,不能作为拖拽起点\n4、当游戏界面中没有相同棋子时,可以点击游戏页面中央的购物袋生成新的棋子\n5、若不知道如何操作,请按照游戏指引进行游玩\n6、不要连续重复上一步操作,合成失败后及时更换策略\n\n请严格按照以上游戏规则,开始游戏\n", "options": { "time_limit": 300, "pre_mark_operation": true diff --git a/examples/game/romantic_restaurant/main_test.go b/examples/game/romantic_restaurant/main_test.go index b028243e..5c44bce6 100644 --- a/examples/game/romantic_restaurant/main_test.go +++ b/examples/game/romantic_restaurant/main_test.go @@ -12,10 +12,11 @@ import ( func TestGameRomanticRestaurant(t *testing.T) { userInstruction := `浪漫餐厅是一款经营类游戏,以下是游戏的基本规则说明: 1、点击右下角锅铲,开始任务 -2、将棋子拖拽至相同棋子,可升级生成新棋子 +2、将棋子拖拽至相同棋子,可升级生成新棋子;注意,必须是相同类别和形状的棋子才能合成,例如,长面包和圆面包不能合成,方形蛋糕和三角形蛋糕不能合成 3、拖拽相同棋子时,被部分遮挡的棋子只能作为拖拽终点,不能作为拖拽起点 4、当游戏界面中没有相同棋子时,可以点击游戏页面中央的购物袋生成新的棋子 5、若不知道如何操作,请按照游戏指引进行游玩 +6、不要连续重复上一步操作,合成失败后及时更换策略 请严格按照以上游戏规则,开始游戏 ` @@ -26,15 +27,22 @@ func TestGameRomanticRestaurant(t *testing.T) { "package_name": "com.ss.android.ugc.aweme", }), TestSteps: []hrp.IStep{ - hrp.NewStep("启动抖音 app"). - Android(). - AppLaunch("$package_name"). - Sleep(5). - Validate(). - AssertAppInForeground("$package_name"), + // hrp.NewStep("启动抖音 app"). + // Android(). + // AppLaunch("$package_name"). + // Sleep(5). + // Validate(). + // AssertAppInForeground("$package_name"), + // hrp.NewStep("进入「浪漫餐厅」小游戏"). + // Android(). + // StartToGoal("搜索「浪漫餐厅」,点击进入「游戏」tab,进入小游戏", + // option.WithPreMarkOperation(true)). + // Validate(). + // AssertAI("当前位于游戏界面"), hrp.NewStep("进入「浪漫餐厅」小游戏"). Android(). - StartToGoal("搜索「浪漫餐厅」,进入小游戏", + Home(). + StartToGoal("在手机桌面点击「浪漫餐厅」启动小游戏,等待游戏加载完成", option.WithPreMarkOperation(true)). Validate(). AssertAI("当前位于游戏界面"), diff --git a/examples/game/sudoku/game_sudoku.json b/examples/game/sudoku/game_sudoku.json index 39c01329..344b9e04 100644 --- a/examples/game/sudoku/game_sudoku.json +++ b/examples/game/sudoku/game_sudoku.json @@ -9,38 +9,17 @@ } }, "teststeps": [ - { - "name": "启动抖音 app", - "android": { - "os_type": "android", - "actions": [ - { - "method": "app_launch", - "params": "$package_name" - }, - { - "method": "sleep", - "params": 5 - } - ] - }, - "validate": [ - { - "check": "ui_foreground_app", - "assert": "equal", - "expect": "$package_name", - "msg": "app [$package_name] should be in foreground" - } - ] - }, { "name": "进入「每天数独」小游戏", "android": { "os_type": "android", "actions": [ + { + "method": "home" + }, { "method": "start_to_goal", - "params": "搜索「每天数独」,进入小游戏", + "params": "在手机桌面点击「每天数独」启动小游戏,等待游戏加载完成\n\n1、点击【开始】按钮开始游戏,进入数独的棋盘界面\n2、若提示「体力不足」,可通过观看广告免费获得体力,观看完成后继续开始游戏\n3、进入棋盘界面后即算作目标达成\n", "options": { "pre_mark_operation": true } @@ -51,8 +30,8 @@ { "check": "ui_ai", "assert": "ai_assert", - "expect": "当前页面底部包含「开始」按钮", - "msg": "assert ai prompt [当前页面底部包含「开始」按钮] failed" + "expect": "当前界面包含网格状的棋盘", + "msg": "assert ai prompt [当前界面包含网格状的棋盘] failed" } ] }, @@ -63,7 +42,7 @@ "actions": [ { "method": "start_to_goal", - "params": "每天数独是一款逻辑推理游戏,玩家需要通过推理来确定黄色方块的所在位置,以下是游戏的基本规则说明:\n1、方块外面的数字代表所在那一行或一列的黄色方块数量。\n2、初始状态为白色方块,选择正确后变为黄色方块,选择错误后变为红底的 X。\n3、如果同一行或列有两个数字,则至少需要一个白底 X 分割它们作为间隔。\n4、如果数字与格子最大数相同时,该列或行必然全都是黄色方块。\n5、只能点击白色方块,不要重复点击同一个方块。\n6、若出现「桌面入口」弹窗则直接关闭。\n\n请严格按照以上游戏规则,开始游戏\n", + "params": "每天数独是一款逻辑推理游戏,玩家需要通过推理来确定黄色方块的所在位置,以下是游戏的基本规则说明:\n1、方块外面的数字代表所在那一行或一列的黄色方块数量。\n2、初始状态为白色方块,选择正确后变为黄色方块,选择错误后变为红底的 X。\n3、如果同一行或列有两个数字,则至少需要一个白底 X 分割它们作为间隔。\n4、如果数字与格子最大数相同时,该列或行必然全都是黄色方块。\n5、只能点击白色方块,不要重复点击同一个方块。\n6、若出现「桌面入口」弹窗则直接关闭。\n7、若游戏失败弹出恢复血量的弹窗,请关闭弹窗重新开始游戏。\n\n请严格按照以上游戏规则,开始游戏\n", "options": { "time_limit": 300, "pre_mark_operation": true diff --git a/examples/game/sudoku/main_test.go b/examples/game/sudoku/main_test.go index 9c68c2b7..4fd287c9 100644 --- a/examples/game/sudoku/main_test.go +++ b/examples/game/sudoku/main_test.go @@ -10,6 +10,12 @@ import ( ) func TestGameSudoku(t *testing.T) { + startGameInstruction := `在手机桌面点击「每天数独」启动小游戏,等待游戏加载完成 + +1、点击【开始】按钮开始游戏,进入数独的棋盘界面 +2、若提示「体力不足」,可通过观看广告免费获得体力,观看完成后继续开始游戏 +3、进入棋盘界面后即算作目标达成 +` userInstruction := `每天数独是一款逻辑推理游戏,玩家需要通过推理来确定黄色方块的所在位置,以下是游戏的基本规则说明: 1、方块外面的数字代表所在那一行或一列的黄色方块数量。 2、初始状态为白色方块,选择正确后变为黄色方块,选择错误后变为红底的 X。 @@ -17,6 +23,7 @@ func TestGameSudoku(t *testing.T) { 4、如果数字与格子最大数相同时,该列或行必然全都是黄色方块。 5、只能点击白色方块,不要重复点击同一个方块。 6、若出现「桌面入口」弹窗则直接关闭。 +7、若游戏失败弹出恢复血量的弹窗,请关闭弹窗重新开始游戏。 请严格按照以上游戏规则,开始游戏 ` @@ -27,18 +34,19 @@ func TestGameSudoku(t *testing.T) { "package_name": "com.ss.android.ugc.aweme", }), TestSteps: []hrp.IStep{ - hrp.NewStep("启动抖音 app"). - Android(). - AppLaunch("$package_name"). - Sleep(5). - Validate(). - AssertAppInForeground("$package_name"), + // hrp.NewStep("启动抖音 app"). + // Android(). + // AppLaunch("$package_name"). + // Sleep(5). + // Validate(). + // AssertAppInForeground("$package_name"), hrp.NewStep("进入「每天数独」小游戏"). Android(). - StartToGoal("搜索「每天数独」,进入小游戏", + Home(). + StartToGoal(startGameInstruction, option.WithPreMarkOperation(true)). Validate(). - AssertAI("当前页面底部包含「开始」按钮"), + AssertAI("当前界面包含网格状的棋盘"), hrp.NewStep("开始游戏"). Android(). StartToGoal(userInstruction, diff --git a/examples/game/yuedongxiaozi/game_yuedongxiaozi.json b/examples/game/yuedongxiaozi/game_yuedongxiaozi.json index a7776210..a26df279 100644 --- a/examples/game/yuedongxiaozi/game_yuedongxiaozi.json +++ b/examples/game/yuedongxiaozi/game_yuedongxiaozi.json @@ -9,38 +9,17 @@ } }, "teststeps": [ - { - "name": "启动抖音 app", - "android": { - "os_type": "android", - "actions": [ - { - "method": "app_launch", - "params": "$package_name" - }, - { - "method": "sleep", - "params": 5 - } - ] - }, - "validate": [ - { - "check": "ui_foreground_app", - "assert": "equal", - "expect": "$package_name", - "msg": "app [$package_name] should be in foreground" - } - ] - }, { "name": "启动「跃动小子」小游戏", "android": { "os_type": "android", "actions": [ + { + "method": "home" + }, { "method": "start_to_goal", - "params": "搜索「跃动小子」,启动小游戏", + "params": "在手机桌面点击「跃动小子」启动小游戏,等待游戏加载完成", "options": { "pre_mark_operation": true } @@ -51,8 +30,8 @@ { "check": "ui_ai", "assert": "ai_assert", - "expect": "当前页面底部包含「领地」「试炼」按钮", - "msg": "assert ai prompt [当前页面底部包含「领地」「试炼」按钮] failed" + "expect": "当前在小游戏页面", + "msg": "assert ai prompt [当前在小游戏页面] failed" } ] }, @@ -63,7 +42,7 @@ "actions": [ { "method": "start_to_goal", - "params": "跃动小子是一款开宝箱类的小游戏,以下是游戏的基本规则说明:\n1、打开宝箱,按照游戏指引进行「出售」或「装备」操作。\n2、请持续推进游戏进程。\n3、屏幕底部的黑白按钮不要进行点击操作。\n\n请严格按照以上游戏规则,开始游戏\n", + "params": "跃动小子是一款开宝箱类的小游戏,以下是游戏的基本规则说明:\n1、打开宝箱,按照游戏指引进行「出售」或「装备」操作。\n2、请持续推进游戏进程。\n3、游戏界面底部的黑白按钮不要进行点击操作。\n\n请严格按照以上游戏规则,开始游戏\n", "options": { "time_limit": 300, "pre_mark_operation": true diff --git a/examples/game/yuedongxiaozi/main_test.go b/examples/game/yuedongxiaozi/main_test.go index 29ac3882..d2ad79a9 100644 --- a/examples/game/yuedongxiaozi/main_test.go +++ b/examples/game/yuedongxiaozi/main_test.go @@ -9,11 +9,11 @@ import ( "github.com/httprunner/httprunner/v5/uixt/option" ) -func TestGameZhuadaE(t *testing.T) { +func TestGameYuedongxiaozi(t *testing.T) { userInstruction := `跃动小子是一款开宝箱类的小游戏,以下是游戏的基本规则说明: 1、打开宝箱,按照游戏指引进行「出售」或「装备」操作。 2、请持续推进游戏进程。 -3、屏幕底部的黑白按钮不要进行点击操作。 +3、游戏界面底部的黑白按钮不要进行点击操作。 请严格按照以上游戏规则,开始游戏 ` @@ -25,18 +25,25 @@ func TestGameZhuadaE(t *testing.T) { "package_name": "com.ss.android.ugc.aweme", }), TestSteps: []hrp.IStep{ - hrp.NewStep("启动抖音 app"). - Android(). - AppLaunch("$package_name"). - Sleep(5). - Validate(). - AssertAppInForeground("$package_name"), + // hrp.NewStep("启动抖音 app"). + // Android(). + // AppLaunch("$package_name"). + // Sleep(5). + // Validate(). + // AssertAppInForeground("$package_name"), + // hrp.NewStep("启动「跃动小子」小游戏"). + // Android(). + // StartToGoal("搜索「跃动小子」,点击「小游戏」tab,进入小游戏", + // option.WithPreMarkOperation(true)). + // Validate(). + // AssertAI("当前在小游戏页面"), hrp.NewStep("启动「跃动小子」小游戏"). Android(). - StartToGoal("搜索「跃动小子」,启动小游戏", + Home(). + StartToGoal("在手机桌面点击「跃动小子」启动小游戏,等待游戏加载完成", option.WithPreMarkOperation(true)). Validate(). - AssertAI("当前页面底部包含「领地」「试炼」按钮"), + AssertAI("当前在小游戏页面"), hrp.NewStep("开始游戏"). Android(). StartToGoal(userInstruction, diff --git a/examples/game/zhuadae/game_zhuadae.json b/examples/game/zhuadae/game_zhuadae.json index 991f6795..d993f23a 100644 --- a/examples/game/zhuadae/game_zhuadae.json +++ b/examples/game/zhuadae/game_zhuadae.json @@ -9,41 +9,24 @@ } }, "teststeps": [ - { - "name": "启动抖音 app", - "android": { - "os_type": "android", - "actions": [ - { - "method": "app_launch", - "params": "$package_name" - }, - { - "method": "sleep", - "params": 5 - } - ] - }, - "validate": [ - { - "check": "ui_foreground_app", - "assert": "equal", - "expect": "$package_name", - "msg": "app [$package_name] should be in foreground" - } - ] - }, { "name": "启动「抓大鹅」小游戏", "android": { "os_type": "android", "actions": [ + { + "method": "home" + }, { "method": "start_to_goal", - "params": "搜索「抓大鹅」,启动小游戏", + "params": "在手机桌面点击「抓大鹅」启动小游戏,处理弹窗,等待游戏加载完成", "options": { "pre_mark_operation": true } + }, + { + "method": "sleep", + "params": 10 } ] }, @@ -51,8 +34,8 @@ { "check": "ui_ai", "assert": "ai_assert", - "expect": "当前页面底部包含「抓大鹅」按钮", - "msg": "assert ai prompt [当前页面底部包含「抓大鹅」按钮] failed" + "expect": "当前页面底部包含「抓大鹅」", + "msg": "assert ai prompt [当前页面底部包含「抓大鹅」] failed" } ] }, @@ -67,6 +50,10 @@ "options": { "pre_mark_operation": true } + }, + { + "method": "sleep", + "params": 10 } ] }, diff --git a/examples/game/zhuadae/main_test.go b/examples/game/zhuadae/main_test.go index bffc5cdf..0a701223 100644 --- a/examples/game/zhuadae/main_test.go +++ b/examples/game/zhuadae/main_test.go @@ -28,22 +28,32 @@ func TestGameZhuadaE(t *testing.T) { "package_name": "com.ss.android.ugc.aweme", }), TestSteps: []hrp.IStep{ - hrp.NewStep("启动抖音 app"). - Android(). - AppLaunch("$package_name"). - Sleep(5). - Validate(). - AssertAppInForeground("$package_name"), + // hrp.NewStep("启动抖音 app"). + // Android(). + // AppLaunch("$package_name"). + // Sleep(5). + // Validate(). + // AssertAppInForeground("$package_name"), + // hrp.NewStep("启动「抓大鹅」小游戏"). + // Android(). + // StartToGoal("搜索「抓大鹅」,启动小游戏", + // option.WithPreMarkOperation(true)). + // Sleep(10). + // Validate(). + // AssertAI("当前页面底部包含「抓大鹅」"), hrp.NewStep("启动「抓大鹅」小游戏"). Android(). - StartToGoal("搜索「抓大鹅」,启动小游戏", + Home(). + StartToGoal("在手机桌面点击「抓大鹅」启动小游戏,处理弹窗,等待游戏加载完成", option.WithPreMarkOperation(true)). + Sleep(10). Validate(). - AssertAI("当前页面底部包含「抓大鹅」按钮"), + AssertAI("当前页面底部包含「抓大鹅」"), hrp.NewStep("进入「抓大鹅」小游戏"). Android(). StartToGoal("点击「抓大鹅」,进入小游戏", option.WithPreMarkOperation(true)). + Sleep(10). Validate(). AssertAI("当前页面底部包含「移出」「凑齐」「打乱」按钮"), hrp.NewStep("开始游戏"). diff --git a/internal/version/VERSION b/internal/version/VERSION index 0e7166d7..552c255e 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-250704 +v5.0.0-250710 diff --git a/parser.go b/parser.go index 6989242a..a7021f15 100644 --- a/parser.go +++ b/parser.go @@ -11,13 +11,13 @@ import ( "strconv" "strings" + "github.com/httprunner/funplugin" + "github.com/httprunner/funplugin/fungo" "github.com/maja42/goval" "github.com/mark3labs/mcp-go/mcp" "github.com/pkg/errors" "github.com/rs/zerolog/log" - "github.com/httprunner/funplugin" - "github.com/httprunner/funplugin/fungo" "github.com/httprunner/httprunner/v5/code" "github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/mcphost" @@ -238,7 +238,7 @@ func (p *Parser) ParseString(raw string, variablesMapping map[string]interface{} log.Debug(). Str("parsedString", parsedString). Int("matchStartPosition", matchStartPosition). - Msg("[parseString] parse function") + Msg("parse string function") continue } @@ -268,7 +268,7 @@ func (p *Parser) ParseString(raw string, variablesMapping map[string]interface{} log.Debug(). Str("parsedString", parsedString). Int("matchStartPosition", matchStartPosition). - Msg("[parseString] parse variable") + Msg("parse string variable") continue } @@ -306,7 +306,8 @@ func (p *Parser) CallFunc(funcName string, arguments ...interface{}) (interface{ // CallMCPTool calls a MCP tool on a specific MCP server func (p *Parser) CallMCPTool(ctx context.Context, serverName, - funcName string, arguments map[string]interface{}) (interface{}, error) { + funcName string, arguments map[string]interface{}, +) (interface{}, error) { if p.MCPHost == nil { return nil, fmt.Errorf("mcphost is not initialized") } diff --git a/report.go b/report.go index 26e78bab..18b44d5f 100644 --- a/report.go +++ b/report.go @@ -1838,6 +1838,101 @@ const htmlTemplate = ` word-wrap: break-word; } + /* AI Assertion Styles */ + .ai-assertion-section { + margin-top: 15px; + padding: 15px; + background: linear-gradient(135deg, #f0f8ff 0%, #f5f5ff 100%); + border: 2px solid #4169e1; + border-radius: 12px; + box-shadow: 0 4px 8px rgba(65, 105, 225, 0.15); + } + + .ai-assertion-section h5 { + margin: 0 0 15px 0; + color: #4169e1; + font-size: 1.1em; + font-weight: 600; + } + + .ai-screenshot-container { + background: white; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 12px; + margin-bottom: 15px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .ai-screenshot { + text-align: center; + margin-top: 10px; + } + + .ai-screenshot img { + max-width: 100%; + height: auto; + border-radius: 8px; + border: 1px solid #dee2e6; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: transform 0.2s; + } + + .ai-screenshot img:hover { + transform: scale(1.02); + } + + .ai-analysis-container { + background: white; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .ai-analysis-content { + margin-top: 10px; + } + + .ai-thought { + background: linear-gradient(135deg, #e8f4fd 0%, #f0f8ff 100%); + border: 1px solid #4169e1; + border-radius: 8px; + padding: 12px; + margin: 10px 0; + color: #2c3e50; + } + + .ai-thought .thought-content { + margin-top: 8px; + font-style: italic; + color: #34495e; + white-space: pre-wrap; + word-wrap: break-word; + } + + .ai-raw-response { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 12px; + margin: 10px 0; + color: #2c3e50; + } + + .ai-raw-response .response-content { + margin-top: 8px; + font-family: monospace; + font-size: 0.9em; + background: white; + padding: 8px; + border-radius: 4px; + border: 1px solid #e9ecef; + white-space: pre-wrap; + word-wrap: break-word; + } + @media screen and (max-width: 768px) { .validator-ai-layout { flex-direction: column; @@ -2950,6 +3045,60 @@ const htmlTemplate = ` {{if and $validator.msg (ne $validator.check_result "pass")}}
{{$validator.msg}}
{{end}} + + + {{if $validator.ai_result}} +
+
🤖 AI Assertion Details
+ + + {{if $validator.ai_result.image_path}} +
+ 📸 AI Assertion Screenshot + {{if $validator.ai_result.screenshot_elapsed}} + {{formatDuration $validator.ai_result.screenshot_elapsed}} + {{end}} +
+ {{$base64Image := encodeImageBase64 $validator.ai_result.image_path}} + {{if $base64Image}} + AI Assertion Screenshot + {{end}} +
+
+ {{end}} + + +
+ 🧠 AI Model Analysis + {{if $validator.ai_result.model_call_elapsed}} + {{formatDuration $validator.ai_result.model_call_elapsed}} + {{end}} +
+ {{if $validator.ai_result.assertion_result.model_name}} +
🤖 Model: {{$validator.ai_result.assertion_result.model_name}}
+ {{end}} + {{if $validator.ai_result.assertion_result.usage}} +
📊 Tokens: {{$validator.ai_result.assertion_result.usage.PromptTokens}} in / {{$validator.ai_result.assertion_result.usage.CompletionTokens}} out / {{$validator.ai_result.assertion_result.usage.TotalTokens}} total
+ {{end}} + {{if $validator.ai_result.resolution}} +
📐 Resolution: {{$validator.ai_result.resolution.Width}}x{{$validator.ai_result.resolution.Height}}
+ {{end}} + {{if $validator.ai_result.assertion_result.thought}} +
+ 💭 AI Reasoning: +
{{$validator.ai_result.assertion_result.thought}}
+
+ {{end}} + {{if $validator.ai_result.assertion_result.content}} +
+ 📝 Raw Model Response: +
{{$validator.ai_result.assertion_result.content}}
+
+ {{end}} +
+
+
+ {{end}} {{end}} diff --git a/step.go b/step.go index 7e5ec98b..636d497b 100644 --- a/step.go +++ b/step.go @@ -37,6 +37,7 @@ type StepConfig struct { Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"` StepExport []string `json:"export,omitempty" yaml:"export,omitempty"` Loops int `json:"loops,omitempty" yaml:"loops,omitempty"` + IgnorePopup bool `json:"ignore_popup,omitempty" yaml:"ignore_popup,omitempty"` // ignore popup for this step, keep for compatibility AutoPopupHandler bool `json:"auto_popup_handler,omitempty" yaml:"auto_popup_handler,omitempty"` // enable auto popup handler for this step } diff --git a/step_ui.go b/step_ui.go index ea641084..e103c255 100644 --- a/step_ui.go +++ b/step_ui.go @@ -715,6 +715,7 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err var stepVariables map[string]interface{} var stepValidators []interface{} var stepAutoPopupHandler bool + var stepIgnorePopup bool var mobileStep *MobileUI switch stepMobile := step.(type) { @@ -722,11 +723,13 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err mobileStep = stepMobile.obj() stepVariables = stepMobile.Variables stepAutoPopupHandler = stepMobile.AutoPopupHandler + stepIgnorePopup = stepMobile.IgnorePopup case *StepMobileUIValidation: mobileStep = stepMobile.obj() stepVariables = stepMobile.Variables stepValidators = stepMobile.Validators stepAutoPopupHandler = stepMobile.StepMobile.AutoPopupHandler + stepIgnorePopup = stepMobile.StepMobile.IgnorePopup default: return stepResult, errors.New("invalid mobile UI step type") } @@ -794,10 +797,14 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err if s.caseRunner != nil && s.caseRunner.Config != nil { config = s.caseRunner.Config.Get() } - // automatic handling of pop-up windows on each step finished - // priority: testcase config > step config, default to disabled + // automatic handling of pop-up windows on each step finished, default to disabled + // priority: step ignore_popup > config auto_popup_handler > step auto_popup_handler shouldHandlePopup := false - if config != nil && config.AutoPopupHandler { + + if stepIgnorePopup { + // step level config, keep for compatibility + shouldHandlePopup = false + } else if config != nil && config.AutoPopupHandler { // testcase level config has higher priority shouldHandlePopup = true } else if stepAutoPopupHandler { @@ -1042,7 +1049,8 @@ func validateUI(ud *uixt.XTDriver, iValidators []interface{}, parser *Parser, st } // Perform validation - err = ud.DoValidation(validator.Check, validator.Assert, expected, validator.Message) + validationResult.AIResult, err = ud.DoValidation( + validator.Check, validator.Assert, expected, validator.Message) if err != nil { // Add the failed validation result to the list before returning error validateResults = append(validateResults, validationResult) diff --git a/summary.go b/summary.go index 28c41101..8909c4d6 100644 --- a/summary.go +++ b/summary.go @@ -11,6 +11,7 @@ import ( "github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/internal/config" "github.com/httprunner/httprunner/v5/internal/version" + "github.com/httprunner/httprunner/v5/uixt" "github.com/httprunner/httprunner/v5/uixt/option" ) @@ -233,6 +234,7 @@ type Address struct { type ValidationResult struct { Validator - CheckValue interface{} `json:"check_value" yaml:"check_value"` - CheckResult string `json:"check_result" yaml:"check_result"` + CheckValue interface{} `json:"check_value" yaml:"check_value"` + CheckResult string `json:"check_result" yaml:"check_result"` + AIResult *uixt.AIExecutionResult `json:"ai_result,omitempty" yaml:"ai_result,omitempty"` // store AI assertion result for displaying in report } diff --git a/uixt/ai/asserter.go b/uixt/ai/asserter.go index 124f4d03..f34e5513 100644 --- a/uixt/ai/asserter.go +++ b/uixt/ai/asserter.go @@ -33,8 +33,9 @@ type AssertOptions struct { type AssertionResult struct { Pass bool `json:"pass"` Thought string `json:"thought"` - ModelName string `json:"model_name"` // model name used for assertion - Usage *schema.TokenUsage `json:"usage,omitempty"` // token usage statistics + Content string `json:"content,omitempty"` // raw response content + ModelName string `json:"model_name"` // model name used for assertion + Usage *schema.TokenUsage `json:"usage,omitempty"` // token usage statistics } // Asserter handles assertion using different AI models @@ -180,5 +181,6 @@ func parseAssertionResult(content string, modelType option.LLMServiceType) (*Ass } result.ModelName = string(modelType) + result.Content = content // Store the original response content return &result, nil } diff --git a/uixt/driver_ext_ai.go b/uixt/driver_ext_ai.go index 48d4025c..84ca4c6d 100644 --- a/uixt/driver_ext_ai.go +++ b/uixt/driver_ext_ai.go @@ -492,9 +492,10 @@ func (dExt *XTDriver) AIAssert(assertion string, opts ...option.ActionOption) (* return assertResult, errors.Wrap(err, "AI assertion failed") } + // For assertion failure, we should still return success but mark the assertion as failed + // This ensures that the AIResult (including screenshot and thought) is properly saved and displayed if !result.Pass { - assertResult.Error = result.Thought - return assertResult, errors.New(result.Thought) + assertResult.Error = result.Thought // Store the failure reason for reporting } return assertResult, nil diff --git a/uixt/driver_session.go b/uixt/driver_session.go index 7445e9b5..4c654398 100644 --- a/uixt/driver_session.go +++ b/uixt/driver_session.go @@ -19,6 +19,7 @@ import ( "github.com/rs/zerolog/log" "github.com/httprunner/httprunner/v5/internal/json" + "github.com/httprunner/httprunner/v5/uixt/option" ) type Attachments map[string]interface{} @@ -151,32 +152,38 @@ func (s *DriverSession) buildURL(urlStr string) (string, error) { return baseURL.ResolveReference(relativeURL).String(), nil } -func (s *DriverSession) GET(urlStr string) (rawResp DriverRawResponse, err error) { - return s.RequestWithRetry(http.MethodGet, urlStr, nil) +func (s *DriverSession) GET(urlStr string, opts ...option.ActionOption) (rawResp DriverRawResponse, err error) { + return s.RequestWithRetry(http.MethodGet, urlStr, nil, opts...) } -func (s *DriverSession) POST(data interface{}, urlStr string) (rawResp DriverRawResponse, err error) { +func (s *DriverSession) POST(data interface{}, urlStr string, opts ...option.ActionOption) (rawResp DriverRawResponse, err error) { var bsJSON []byte = nil if data != nil { if bsJSON, err = json.Marshal(data); err != nil { return nil, err } } - return s.RequestWithRetry(http.MethodPost, urlStr, bsJSON) + return s.RequestWithRetry(http.MethodPost, urlStr, bsJSON, opts...) } -func (s *DriverSession) DELETE(urlStr string) (rawResp DriverRawResponse, err error) { - return s.RequestWithRetry(http.MethodDelete, urlStr, nil) +func (s *DriverSession) DELETE(urlStr string, opts ...option.ActionOption) (rawResp DriverRawResponse, err error) { + return s.RequestWithRetry(http.MethodDelete, urlStr, nil, opts...) } -func (s *DriverSession) RequestWithRetry(method string, urlStr string, rawBody []byte) ( +func (s *DriverSession) RequestWithRetry(method string, urlStr string, rawBody []byte, opts ...option.ActionOption) ( rawResp DriverRawResponse, err error, ) { var lastError error - for attempt := 1; attempt <= s.maxRetry; attempt++ { + maxRetry := s.maxRetry + options := option.NewActionOptions(opts...) + if options.MaxRetryTimes > 0 { + maxRetry = options.MaxRetryTimes + } + + for attempt := 1; attempt <= maxRetry; attempt++ { // Execute the request - rawResp, err = s.Request(method, urlStr, rawBody) + rawResp, err = s.Request(method, urlStr, rawBody, opts...) if err == nil { if attempt > 1 { log.Info().Msgf("request succeeded after %d attempts", attempt) @@ -210,9 +217,15 @@ func (s *DriverSession) RequestWithRetry(method string, urlStr string, rawBody [ return nil, lastError } -func (s *DriverSession) Request(method string, urlStr string, rawBody []byte) ( +func (s *DriverSession) Request(method string, urlStr string, rawBody []byte, opts ...option.ActionOption) ( rawResp DriverRawResponse, err error, ) { + timeout := s.timeout + options := option.NewActionOptions(opts...) + if options.Timeout > 0 { + timeout = time.Duration(options.Timeout) * time.Second + } + // build final URL rawURL, err := s.buildURL(urlStr) if err != nil { @@ -252,7 +265,7 @@ func (s *DriverSession) Request(method string, urlStr string, rawBody []byte) ( logger.Msg("request uixt driver") }() - ctx, cancel := context.WithTimeout(s.ctx, s.timeout) + ctx, cancel := context.WithTimeout(s.ctx, timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewBuffer(rawBody)) diff --git a/uixt/driver_utils.go b/uixt/driver_utils.go index c24abcc6..7538297e 100644 --- a/uixt/driver_utils.go +++ b/uixt/driver_utils.go @@ -65,8 +65,8 @@ func convertToAbsolutePoint(driver IDriver, x, y float64) (absX, absY float64, e } func convertToAbsoluteCoordinates(driver IDriver, fromX, fromY, toX, toY float64) ( - absFromX, absFromY, absToX, absToY float64, err error) { - + absFromX, absFromY, absToX, absToY float64, err error, +) { // absolute coordinates if fromX > 1 || toX > 1 || fromY > 1 || toY > 1 { return fromX, fromY, toX, toY, nil @@ -190,32 +190,43 @@ func (dExt *XTDriver) assertSelector(selector, assert string) error { return nil } -func (dExt *XTDriver) DoValidation(check, assert, expected string, message ...string) (err error) { +func (dExt *XTDriver) DoValidation(check, assert, expected string, message ...string) (aiResult *AIExecutionResult, err error) { switch check { case option.SelectorOCR: err = dExt.assertOCR(expected, assert) case option.SelectorAI: - _, err = dExt.AIAssert(expected) + aiResult, err = dExt.AIAssert(expected) case option.SelectorForegroundApp: err = dExt.assertForegroundApp(expected, assert) case option.SelectorSelector: err = dExt.assertSelector(expected, assert) default: - return fmt.Errorf("validator %s not implemented", check) + return nil, fmt.Errorf("validator %s not implemented", check) } if err != nil { + // Technical error (not assertion failure) if message == nil { message = []string{""} } log.Error().Err(err).Str("assert", assert).Str("expect", expected). Str("msg", message[0]).Msg("validate failed") - return err + return nil, err + } else if aiResult != nil { + // Check assertion result instead of relying on error + if !aiResult.AssertionResult.Pass { + return aiResult, errors.New(aiResult.AssertionResult.Thought) + } + log.Info().Str("check", check).Str("assert", assert). + Str("expect", expected). + Interface("ai_assertion_result", aiResult.AssertionResult). + Msg("ai assertion passed") + return aiResult, nil + } else { + log.Info().Str("check", check).Str("assert", assert). + Str("expect", expected).Msg("validate success") + return nil, nil } - - log.Info().Str("check", check).Str("assert", assert). - Str("expect", expected).Msg("validate success") - return nil } type SleepConfig struct { diff --git a/uixt/ios_device.go b/uixt/ios_device.go index 658892c1..0c220006 100644 --- a/uixt/ios_device.go +++ b/uixt/ios_device.go @@ -160,22 +160,22 @@ func (dev *IOSDevice) Setup() error { if version.GreaterThan(semver.MustParse("17.4.0")) { info, err := tunnel.TunnelInfoForDevice(dev.DeviceEntry.Properties.SerialNumber, ios.HttpApiHost(), ios.HttpApiPort()) if err != nil { - return err + return errors.Wrap(code.DeviceConnectionError, err.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 + return errors.Wrap(code.DeviceConnectionError, err.Error()) } defer rsdService.Close() rsdProvider, err := rsdService.Handshake() if err != nil { - return err + return errors.Wrap(code.DeviceConnectionError, err.Error()) } device, err := ios.GetDeviceWithAddress(dev.DeviceEntry.Properties.SerialNumber, info.Address, rsdProvider) if err != nil { - return err + return errors.Wrap(code.DeviceConnectionError, err.Error()) } device.UserspaceTUN = dev.DeviceEntry.UserspaceTUN device.UserspaceTUNPort = dev.DeviceEntry.UserspaceTUNPort @@ -470,7 +470,7 @@ func (dev *IOSDevice) getVersion() (version *semver.Version, err error) { version, err = ios.GetProductVersion(dev.DeviceEntry) if err != nil { log.Error().Err(err).Msg("failed to get version") - return nil, err + return nil, errors.Wrap(code.DeviceGetInfoError, err.Error()) } log.Info().Str("version", version.String()).Msg("get ios device version") return version, nil