Merge branch 'master' into bugfix/huangbin/web_v5

This commit is contained in:
huangbin.beal
2025-07-10 14:03:22 +08:00
19 changed files with 330 additions and 185 deletions
@@ -9,38 +9,17 @@
} }
}, },
"teststeps": [ "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": "进入「浪漫餐厅」小游戏", "name": "进入「浪漫餐厅」小游戏",
"android": { "android": {
"os_type": "android", "os_type": "android",
"actions": [ "actions": [
{
"method": "home"
},
{ {
"method": "start_to_goal", "method": "start_to_goal",
"params": "搜索「浪漫餐厅」,进入小游戏", "params": "在手机桌面点击「浪漫餐厅」启动小游戏,等待游戏加载完成",
"options": { "options": {
"pre_mark_operation": true "pre_mark_operation": true
} }
@@ -63,7 +42,7 @@
"actions": [ "actions": [
{ {
"method": "start_to_goal", "method": "start_to_goal",
"params": "浪漫餐厅是一款经营类游戏,以下是游戏的基本规则说明:\n1、点击右下角锅铲,开始任务\n2、将棋子拖拽至相同棋子,可升级生成新棋子\n3、拖拽相同棋子时,被部分遮挡的棋子只能作为拖拽终点,不能作为拖拽起点\n4、当游戏界面中没有相同棋子时,可以点击游戏页面中央的购物袋生成新的棋子\n5、若不知道如何操作,请按照游戏指引进行游玩\n\n请严格按照以上游戏规则,开始游戏\n", "params": "浪漫餐厅是一款经营类游戏,以下是游戏的基本规则说明:\n1、点击右下角锅铲,开始任务\n2、将棋子拖拽至相同棋子,可升级生成新棋子;注意,必须是相同类别和形状的棋子才能合成,例如,长面包和圆面包不能合成,方形蛋糕和三角形蛋糕不能合成\n3、拖拽相同棋子时,被部分遮挡的棋子只能作为拖拽终点,不能作为拖拽起点\n4、当游戏界面中没有相同棋子时,可以点击游戏页面中央的购物袋生成新的棋子\n5、若不知道如何操作,请按照游戏指引进行游玩\n6、不要连续重复上一步操作,合成失败后及时更换策略\n\n请严格按照以上游戏规则,开始游戏\n",
"options": { "options": {
"time_limit": 300, "time_limit": 300,
"pre_mark_operation": true "pre_mark_operation": true
+16 -8
View File
@@ -12,10 +12,11 @@ import (
func TestGameRomanticRestaurant(t *testing.T) { func TestGameRomanticRestaurant(t *testing.T) {
userInstruction := `浪漫餐厅是一款经营类游戏,以下是游戏的基本规则说明: userInstruction := `浪漫餐厅是一款经营类游戏,以下是游戏的基本规则说明:
1、点击右下角锅铲,开始任务 1、点击右下角锅铲,开始任务
2、将棋子拖拽至相同棋子,可升级生成新棋子 2、将棋子拖拽至相同棋子,可升级生成新棋子;注意,必须是相同类别和形状的棋子才能合成,例如,长面包和圆面包不能合成,方形蛋糕和三角形蛋糕不能合成
3、拖拽相同棋子时,被部分遮挡的棋子只能作为拖拽终点,不能作为拖拽起点 3、拖拽相同棋子时,被部分遮挡的棋子只能作为拖拽终点,不能作为拖拽起点
4、当游戏界面中没有相同棋子时,可以点击游戏页面中央的购物袋生成新的棋子 4、当游戏界面中没有相同棋子时,可以点击游戏页面中央的购物袋生成新的棋子
5、若不知道如何操作,请按照游戏指引进行游玩 5、若不知道如何操作,请按照游戏指引进行游玩
6、不要连续重复上一步操作,合成失败后及时更换策略
请严格按照以上游戏规则,开始游戏 请严格按照以上游戏规则,开始游戏
` `
@@ -26,15 +27,22 @@ func TestGameRomanticRestaurant(t *testing.T) {
"package_name": "com.ss.android.ugc.aweme", "package_name": "com.ss.android.ugc.aweme",
}), }),
TestSteps: []hrp.IStep{ TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音 app"). // hrp.NewStep("启动抖音 app").
Android(). // Android().
AppLaunch("$package_name"). // AppLaunch("$package_name").
Sleep(5). // Sleep(5).
Validate(). // Validate().
AssertAppInForeground("$package_name"), // AssertAppInForeground("$package_name"),
// hrp.NewStep("进入「浪漫餐厅」小游戏").
// Android().
// StartToGoal("搜索「浪漫餐厅」,点击进入「游戏」tab,进入小游戏",
// option.WithPreMarkOperation(true)).
// Validate().
// AssertAI("当前位于游戏界面"),
hrp.NewStep("进入「浪漫餐厅」小游戏"). hrp.NewStep("进入「浪漫餐厅」小游戏").
Android(). Android().
StartToGoal("搜索「浪漫餐厅」,进入小游戏", Home().
StartToGoal("在手机桌面点击「浪漫餐厅」启动小游戏,等待游戏加载完成",
option.WithPreMarkOperation(true)). option.WithPreMarkOperation(true)).
Validate(). Validate().
AssertAI("当前位于游戏界面"), AssertAI("当前位于游戏界面"),
+7 -28
View File
@@ -9,38 +9,17 @@
} }
}, },
"teststeps": [ "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": "进入「每天数独」小游戏", "name": "进入「每天数独」小游戏",
"android": { "android": {
"os_type": "android", "os_type": "android",
"actions": [ "actions": [
{
"method": "home"
},
{ {
"method": "start_to_goal", "method": "start_to_goal",
"params": "搜索「每天数独」,进入小游戏", "params": "在手机桌面点击「每天数独」启动小游戏,等待游戏加载完成\n\n1、点击【开始】按钮开始游戏,进入数独的棋盘界面\n2、若提示「体力不足」,可通过观看广告免费获得体力,观看完成后继续开始游戏\n3、进入棋盘界面后即算作目标达成\n",
"options": { "options": {
"pre_mark_operation": true "pre_mark_operation": true
} }
@@ -51,8 +30,8 @@
{ {
"check": "ui_ai", "check": "ui_ai",
"assert": "ai_assert", "assert": "ai_assert",
"expect": "当前页面底部包含「开始」按钮", "expect": "当前界面包含网格状的棋盘",
"msg": "assert ai prompt [当前页面底部包含「开始」按钮] failed" "msg": "assert ai prompt [当前界面包含网格状的棋盘] failed"
} }
] ]
}, },
@@ -63,7 +42,7 @@
"actions": [ "actions": [
{ {
"method": "start_to_goal", "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": { "options": {
"time_limit": 300, "time_limit": 300,
"pre_mark_operation": true "pre_mark_operation": true
+16 -8
View File
@@ -10,6 +10,12 @@ import (
) )
func TestGameSudoku(t *testing.T) { func TestGameSudoku(t *testing.T) {
startGameInstruction := `在手机桌面点击「每天数独」启动小游戏,等待游戏加载完成
1、点击【开始】按钮开始游戏,进入数独的棋盘界面
2、若提示「体力不足」,可通过观看广告免费获得体力,观看完成后继续开始游戏
3、进入棋盘界面后即算作目标达成
`
userInstruction := `每天数独是一款逻辑推理游戏,玩家需要通过推理来确定黄色方块的所在位置,以下是游戏的基本规则说明: userInstruction := `每天数独是一款逻辑推理游戏,玩家需要通过推理来确定黄色方块的所在位置,以下是游戏的基本规则说明:
1、方块外面的数字代表所在那一行或一列的黄色方块数量。 1、方块外面的数字代表所在那一行或一列的黄色方块数量。
2、初始状态为白色方块,选择正确后变为黄色方块,选择错误后变为红底的 X。 2、初始状态为白色方块,选择正确后变为黄色方块,选择错误后变为红底的 X。
@@ -17,6 +23,7 @@ func TestGameSudoku(t *testing.T) {
4、如果数字与格子最大数相同时,该列或行必然全都是黄色方块。 4、如果数字与格子最大数相同时,该列或行必然全都是黄色方块。
5、只能点击白色方块,不要重复点击同一个方块。 5、只能点击白色方块,不要重复点击同一个方块。
6、若出现「桌面入口」弹窗则直接关闭。 6、若出现「桌面入口」弹窗则直接关闭。
7、若游戏失败弹出恢复血量的弹窗,请关闭弹窗重新开始游戏。
请严格按照以上游戏规则,开始游戏 请严格按照以上游戏规则,开始游戏
` `
@@ -27,18 +34,19 @@ func TestGameSudoku(t *testing.T) {
"package_name": "com.ss.android.ugc.aweme", "package_name": "com.ss.android.ugc.aweme",
}), }),
TestSteps: []hrp.IStep{ TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音 app"). // hrp.NewStep("启动抖音 app").
Android(). // Android().
AppLaunch("$package_name"). // AppLaunch("$package_name").
Sleep(5). // Sleep(5).
Validate(). // Validate().
AssertAppInForeground("$package_name"), // AssertAppInForeground("$package_name"),
hrp.NewStep("进入「每天数独」小游戏"). hrp.NewStep("进入「每天数独」小游戏").
Android(). Android().
StartToGoal("搜索「每天数独」,进入小游戏", Home().
StartToGoal(startGameInstruction,
option.WithPreMarkOperation(true)). option.WithPreMarkOperation(true)).
Validate(). Validate().
AssertAI("当前页面底部包含「开始」按钮"), AssertAI("当前界面包含网格状的棋盘"),
hrp.NewStep("开始游戏"). hrp.NewStep("开始游戏").
Android(). Android().
StartToGoal(userInstruction, StartToGoal(userInstruction,
@@ -9,38 +9,17 @@
} }
}, },
"teststeps": [ "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": "启动「跃动小子」小游戏", "name": "启动「跃动小子」小游戏",
"android": { "android": {
"os_type": "android", "os_type": "android",
"actions": [ "actions": [
{
"method": "home"
},
{ {
"method": "start_to_goal", "method": "start_to_goal",
"params": "搜索「跃动小子」启动小游戏", "params": "在手机桌面点击「跃动小子」启动小游戏,等待游戏加载完成",
"options": { "options": {
"pre_mark_operation": true "pre_mark_operation": true
} }
@@ -51,8 +30,8 @@
{ {
"check": "ui_ai", "check": "ui_ai",
"assert": "ai_assert", "assert": "ai_assert",
"expect": "当前页面底部包含「领地」「试炼」按钮", "expect": "当前在小游戏页面",
"msg": "assert ai prompt [当前页面底部包含「领地」「试炼」按钮] failed" "msg": "assert ai prompt [当前在小游戏页面] failed"
} }
] ]
}, },
@@ -63,7 +42,7 @@
"actions": [ "actions": [
{ {
"method": "start_to_goal", "method": "start_to_goal",
"params": "跃动小子是一款开宝箱类的小游戏,以下是游戏的基本规则说明:\n1、打开宝箱,按照游戏指引进行「出售」或「装备」操作。\n2、请持续推进游戏进程。\n3、屏幕底部的黑白按钮不要进行点击操作。\n\n请严格按照以上游戏规则,开始游戏\n", "params": "跃动小子是一款开宝箱类的小游戏,以下是游戏的基本规则说明:\n1、打开宝箱,按照游戏指引进行「出售」或「装备」操作。\n2、请持续推进游戏进程。\n3、游戏界面底部的黑白按钮不要进行点击操作。\n\n请严格按照以上游戏规则,开始游戏\n",
"options": { "options": {
"time_limit": 300, "time_limit": 300,
"pre_mark_operation": true "pre_mark_operation": true
+17 -10
View File
@@ -9,11 +9,11 @@ import (
"github.com/httprunner/httprunner/v5/uixt/option" "github.com/httprunner/httprunner/v5/uixt/option"
) )
func TestGameZhuadaE(t *testing.T) { func TestGameYuedongxiaozi(t *testing.T) {
userInstruction := `跃动小子是一款开宝箱类的小游戏,以下是游戏的基本规则说明: userInstruction := `跃动小子是一款开宝箱类的小游戏,以下是游戏的基本规则说明:
1、打开宝箱,按照游戏指引进行「出售」或「装备」操作。 1、打开宝箱,按照游戏指引进行「出售」或「装备」操作。
2、请持续推进游戏进程。 2、请持续推进游戏进程。
3、屏幕底部的黑白按钮不要进行点击操作。 3、游戏界面底部的黑白按钮不要进行点击操作。
请严格按照以上游戏规则,开始游戏 请严格按照以上游戏规则,开始游戏
` `
@@ -25,18 +25,25 @@ func TestGameZhuadaE(t *testing.T) {
"package_name": "com.ss.android.ugc.aweme", "package_name": "com.ss.android.ugc.aweme",
}), }),
TestSteps: []hrp.IStep{ TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音 app"). // hrp.NewStep("启动抖音 app").
Android(). // Android().
AppLaunch("$package_name"). // AppLaunch("$package_name").
Sleep(5). // Sleep(5).
Validate(). // Validate().
AssertAppInForeground("$package_name"), // AssertAppInForeground("$package_name"),
// hrp.NewStep("启动「跃动小子」小游戏").
// Android().
// StartToGoal("搜索「跃动小子」,点击「小游戏」tab,进入小游戏",
// option.WithPreMarkOperation(true)).
// Validate().
// AssertAI("当前在小游戏页面"),
hrp.NewStep("启动「跃动小子」小游戏"). hrp.NewStep("启动「跃动小子」小游戏").
Android(). Android().
StartToGoal("搜索「跃动小子」,启动小游戏", Home().
StartToGoal("在手机桌面点击「跃动小子」启动小游戏,等待游戏加载完成",
option.WithPreMarkOperation(true)). option.WithPreMarkOperation(true)).
Validate(). Validate().
AssertAI("当前页面底部包含「领地」「试炼」按钮"), AssertAI("当前在小游戏页面"),
hrp.NewStep("开始游戏"). hrp.NewStep("开始游戏").
Android(). Android().
StartToGoal(userInstruction, StartToGoal(userInstruction,
+14 -27
View File
@@ -9,41 +9,24 @@
} }
}, },
"teststeps": [ "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": "启动「抓大鹅」小游戏", "name": "启动「抓大鹅」小游戏",
"android": { "android": {
"os_type": "android", "os_type": "android",
"actions": [ "actions": [
{
"method": "home"
},
{ {
"method": "start_to_goal", "method": "start_to_goal",
"params": "搜索「抓大鹅」启动小游戏", "params": "在手机桌面点击「抓大鹅」启动小游戏,处理弹窗,等待游戏加载完成",
"options": { "options": {
"pre_mark_operation": true "pre_mark_operation": true
} }
},
{
"method": "sleep",
"params": 10
} }
] ]
}, },
@@ -51,8 +34,8 @@
{ {
"check": "ui_ai", "check": "ui_ai",
"assert": "ai_assert", "assert": "ai_assert",
"expect": "当前页面底部包含「抓大鹅」按钮", "expect": "当前页面底部包含「抓大鹅」",
"msg": "assert ai prompt [当前页面底部包含「抓大鹅」按钮] failed" "msg": "assert ai prompt [当前页面底部包含「抓大鹅」] failed"
} }
] ]
}, },
@@ -67,6 +50,10 @@
"options": { "options": {
"pre_mark_operation": true "pre_mark_operation": true
} }
},
{
"method": "sleep",
"params": 10
} }
] ]
}, },
+18 -8
View File
@@ -28,22 +28,32 @@ func TestGameZhuadaE(t *testing.T) {
"package_name": "com.ss.android.ugc.aweme", "package_name": "com.ss.android.ugc.aweme",
}), }),
TestSteps: []hrp.IStep{ TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音 app"). // hrp.NewStep("启动抖音 app").
Android(). // Android().
AppLaunch("$package_name"). // AppLaunch("$package_name").
Sleep(5). // Sleep(5).
Validate(). // Validate().
AssertAppInForeground("$package_name"), // AssertAppInForeground("$package_name"),
// hrp.NewStep("启动「抓大鹅」小游戏").
// Android().
// StartToGoal("搜索「抓大鹅」,启动小游戏",
// option.WithPreMarkOperation(true)).
// Sleep(10).
// Validate().
// AssertAI("当前页面底部包含「抓大鹅」"),
hrp.NewStep("启动「抓大鹅」小游戏"). hrp.NewStep("启动「抓大鹅」小游戏").
Android(). Android().
StartToGoal("搜索「抓大鹅」,启动小游戏", Home().
StartToGoal("在手机桌面点击「抓大鹅」启动小游戏,处理弹窗,等待游戏加载完成",
option.WithPreMarkOperation(true)). option.WithPreMarkOperation(true)).
Sleep(10).
Validate(). Validate().
AssertAI("当前页面底部包含「抓大鹅」按钮"), AssertAI("当前页面底部包含「抓大鹅」"),
hrp.NewStep("进入「抓大鹅」小游戏"). hrp.NewStep("进入「抓大鹅」小游戏").
Android(). Android().
StartToGoal("点击「抓大鹅」,进入小游戏", StartToGoal("点击「抓大鹅」,进入小游戏",
option.WithPreMarkOperation(true)). option.WithPreMarkOperation(true)).
Sleep(10).
Validate(). Validate().
AssertAI("当前页面底部包含「移出」「凑齐」「打乱」按钮"), AssertAI("当前页面底部包含「移出」「凑齐」「打乱」按钮"),
hrp.NewStep("开始游戏"). hrp.NewStep("开始游戏").
+1 -1
View File
@@ -1 +1 @@
v5.0.0-250704 v5.0.0-250710
+6 -5
View File
@@ -11,13 +11,13 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/httprunner/funplugin"
"github.com/httprunner/funplugin/fungo"
"github.com/maja42/goval" "github.com/maja42/goval"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/rs/zerolog/log" "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/code"
"github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/mcphost" "github.com/httprunner/httprunner/v5/mcphost"
@@ -238,7 +238,7 @@ func (p *Parser) ParseString(raw string, variablesMapping map[string]interface{}
log.Debug(). log.Debug().
Str("parsedString", parsedString). Str("parsedString", parsedString).
Int("matchStartPosition", matchStartPosition). Int("matchStartPosition", matchStartPosition).
Msg("[parseString] parse function") Msg("parse string function")
continue continue
} }
@@ -268,7 +268,7 @@ func (p *Parser) ParseString(raw string, variablesMapping map[string]interface{}
log.Debug(). log.Debug().
Str("parsedString", parsedString). Str("parsedString", parsedString).
Int("matchStartPosition", matchStartPosition). Int("matchStartPosition", matchStartPosition).
Msg("[parseString] parse variable") Msg("parse string variable")
continue continue
} }
@@ -306,7 +306,8 @@ func (p *Parser) CallFunc(funcName string, arguments ...interface{}) (interface{
// CallMCPTool calls a MCP tool on a specific MCP server // CallMCPTool calls a MCP tool on a specific MCP server
func (p *Parser) CallMCPTool(ctx context.Context, serverName, 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 { if p.MCPHost == nil {
return nil, fmt.Errorf("mcphost is not initialized") return nil, fmt.Errorf("mcphost is not initialized")
} }
+149
View File
@@ -1838,6 +1838,101 @@ const htmlTemplate = `<!DOCTYPE html>
word-wrap: break-word; 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) { @media screen and (max-width: 768px) {
.validator-ai-layout { .validator-ai-layout {
flex-direction: column; flex-direction: column;
@@ -2950,6 +3045,60 @@ const htmlTemplate = `<!DOCTYPE html>
{{if and $validator.msg (ne $validator.check_result "pass")}} {{if and $validator.msg (ne $validator.check_result "pass")}}
<div class="validator-message">{{$validator.msg}}</div> <div class="validator-message">{{$validator.msg}}</div>
{{end}} {{end}}
<!-- AI Assertion Results -->
{{if $validator.ai_result}}
<div class="ai-assertion-section">
<h5>🤖 AI Assertion Details</h5>
<!-- AI Assertion Screenshot -->
{{if $validator.ai_result.image_path}}
<div class="ai-screenshot-container">
<span class="step-name">📸 AI Assertion Screenshot</span>
{{if $validator.ai_result.screenshot_elapsed}}
<span class="duration">{{formatDuration $validator.ai_result.screenshot_elapsed}}</span>
{{end}}
<div class="ai-screenshot">
{{$base64Image := encodeImageBase64 $validator.ai_result.image_path}}
{{if $base64Image}}
<img src="data:image/jpeg;base64,{{$base64Image}}" alt="AI Assertion Screenshot" onclick="openImageModal(this.src)" />
{{end}}
</div>
</div>
{{end}}
<!-- AI Model Analysis -->
<div class="ai-analysis-container">
<span class="step-name">🧠 AI Model Analysis</span>
{{if $validator.ai_result.model_call_elapsed}}
<span class="duration">{{formatDuration $validator.ai_result.model_call_elapsed}}</span>
{{end}}
<div class="ai-analysis-content">
{{if $validator.ai_result.assertion_result.model_name}}
<div class="model-info">🤖 Model: {{$validator.ai_result.assertion_result.model_name}}</div>
{{end}}
{{if $validator.ai_result.assertion_result.usage}}
<div class="usage-info">📊 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</div>
{{end}}
{{if $validator.ai_result.resolution}}
<div class="model-info">📐 Resolution: {{$validator.ai_result.resolution.Width}}x{{$validator.ai_result.resolution.Height}}</div>
{{end}}
{{if $validator.ai_result.assertion_result.thought}}
<div class="ai-thought">
<strong>💭 AI Reasoning:</strong>
<div class="thought-content">{{$validator.ai_result.assertion_result.thought}}</div>
</div>
{{end}}
{{if $validator.ai_result.assertion_result.content}}
<div class="ai-raw-response">
<strong>📝 Raw Model Response:</strong>
<div class="response-content">{{$validator.ai_result.assertion_result.content}}</div>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>
+1
View File
@@ -37,6 +37,7 @@ type StepConfig struct {
Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"` Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"`
StepExport []string `json:"export,omitempty" yaml:"export,omitempty"` StepExport []string `json:"export,omitempty" yaml:"export,omitempty"`
Loops int `json:"loops,omitempty" yaml:"loops,omitempty"` Loops 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 AutoPopupHandler bool `json:"auto_popup_handler,omitempty" yaml:"auto_popup_handler,omitempty"` // enable auto popup handler for this step
} }
+12 -4
View File
@@ -715,6 +715,7 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
var stepVariables map[string]interface{} var stepVariables map[string]interface{}
var stepValidators []interface{} var stepValidators []interface{}
var stepAutoPopupHandler bool var stepAutoPopupHandler bool
var stepIgnorePopup bool
var mobileStep *MobileUI var mobileStep *MobileUI
switch stepMobile := step.(type) { switch stepMobile := step.(type) {
@@ -722,11 +723,13 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
mobileStep = stepMobile.obj() mobileStep = stepMobile.obj()
stepVariables = stepMobile.Variables stepVariables = stepMobile.Variables
stepAutoPopupHandler = stepMobile.AutoPopupHandler stepAutoPopupHandler = stepMobile.AutoPopupHandler
stepIgnorePopup = stepMobile.IgnorePopup
case *StepMobileUIValidation: case *StepMobileUIValidation:
mobileStep = stepMobile.obj() mobileStep = stepMobile.obj()
stepVariables = stepMobile.Variables stepVariables = stepMobile.Variables
stepValidators = stepMobile.Validators stepValidators = stepMobile.Validators
stepAutoPopupHandler = stepMobile.StepMobile.AutoPopupHandler stepAutoPopupHandler = stepMobile.StepMobile.AutoPopupHandler
stepIgnorePopup = stepMobile.StepMobile.IgnorePopup
default: default:
return stepResult, errors.New("invalid mobile UI step type") 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 { if s.caseRunner != nil && s.caseRunner.Config != nil {
config = s.caseRunner.Config.Get() config = s.caseRunner.Config.Get()
} }
// automatic handling of pop-up windows on each step finished // automatic handling of pop-up windows on each step finished, default to disabled
// priority: testcase config > step config, default to disabled // priority: step ignore_popup > config auto_popup_handler > step auto_popup_handler
shouldHandlePopup := false 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 // testcase level config has higher priority
shouldHandlePopup = true shouldHandlePopup = true
} else if stepAutoPopupHandler { } else if stepAutoPopupHandler {
@@ -1042,7 +1049,8 @@ func validateUI(ud *uixt.XTDriver, iValidators []interface{}, parser *Parser, st
} }
// Perform validation // 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 { if err != nil {
// Add the failed validation result to the list before returning error // Add the failed validation result to the list before returning error
validateResults = append(validateResults, validationResult) validateResults = append(validateResults, validationResult)
+4 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/internal/config" "github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/internal/version" "github.com/httprunner/httprunner/v5/internal/version"
"github.com/httprunner/httprunner/v5/uixt"
"github.com/httprunner/httprunner/v5/uixt/option" "github.com/httprunner/httprunner/v5/uixt/option"
) )
@@ -233,6 +234,7 @@ type Address struct {
type ValidationResult struct { type ValidationResult struct {
Validator Validator
CheckValue interface{} `json:"check_value" yaml:"check_value"` CheckValue interface{} `json:"check_value" yaml:"check_value"`
CheckResult string `json:"check_result" yaml:"check_result"` 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
} }
+4 -2
View File
@@ -33,8 +33,9 @@ type AssertOptions struct {
type AssertionResult struct { type AssertionResult struct {
Pass bool `json:"pass"` Pass bool `json:"pass"`
Thought string `json:"thought"` Thought string `json:"thought"`
ModelName string `json:"model_name"` // model name used for assertion Content string `json:"content,omitempty"` // raw response content
Usage *schema.TokenUsage `json:"usage,omitempty"` // token usage statistics 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 // Asserter handles assertion using different AI models
@@ -180,5 +181,6 @@ func parseAssertionResult(content string, modelType option.LLMServiceType) (*Ass
} }
result.ModelName = string(modelType) result.ModelName = string(modelType)
result.Content = content // Store the original response content
return &result, nil return &result, nil
} }
+3 -2
View File
@@ -492,9 +492,10 @@ func (dExt *XTDriver) AIAssert(assertion string, opts ...option.ActionOption) (*
return assertResult, errors.Wrap(err, "AI assertion failed") 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 { if !result.Pass {
assertResult.Error = result.Thought assertResult.Error = result.Thought // Store the failure reason for reporting
return assertResult, errors.New(result.Thought)
} }
return assertResult, nil return assertResult, nil
+24 -11
View File
@@ -19,6 +19,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/internal/json" "github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/uixt/option"
) )
type Attachments map[string]interface{} type Attachments map[string]interface{}
@@ -151,32 +152,38 @@ func (s *DriverSession) buildURL(urlStr string) (string, error) {
return baseURL.ResolveReference(relativeURL).String(), nil return baseURL.ResolveReference(relativeURL).String(), nil
} }
func (s *DriverSession) GET(urlStr string) (rawResp DriverRawResponse, err error) { func (s *DriverSession) GET(urlStr string, opts ...option.ActionOption) (rawResp DriverRawResponse, err error) {
return s.RequestWithRetry(http.MethodGet, urlStr, nil) 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 var bsJSON []byte = nil
if data != nil { if data != nil {
if bsJSON, err = json.Marshal(data); err != nil { if bsJSON, err = json.Marshal(data); err != nil {
return nil, err 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) { func (s *DriverSession) DELETE(urlStr string, opts ...option.ActionOption) (rawResp DriverRawResponse, err error) {
return s.RequestWithRetry(http.MethodDelete, urlStr, nil) 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, rawResp DriverRawResponse, err error,
) { ) {
var lastError 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 // Execute the request
rawResp, err = s.Request(method, urlStr, rawBody) rawResp, err = s.Request(method, urlStr, rawBody, opts...)
if err == nil { if err == nil {
if attempt > 1 { if attempt > 1 {
log.Info().Msgf("request succeeded after %d attempts", attempt) 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 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, 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 // build final URL
rawURL, err := s.buildURL(urlStr) rawURL, err := s.buildURL(urlStr)
if err != nil { if err != nil {
@@ -252,7 +265,7 @@ func (s *DriverSession) Request(method string, urlStr string, rawBody []byte) (
logger.Msg("request uixt driver") logger.Msg("request uixt driver")
}() }()
ctx, cancel := context.WithTimeout(s.ctx, s.timeout) ctx, cancel := context.WithTimeout(s.ctx, timeout)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewBuffer(rawBody)) req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewBuffer(rawBody))
+21 -10
View File
@@ -65,8 +65,8 @@ func convertToAbsolutePoint(driver IDriver, x, y float64) (absX, absY float64, e
} }
func convertToAbsoluteCoordinates(driver IDriver, fromX, fromY, toX, toY float64) ( 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 // absolute coordinates
if fromX > 1 || toX > 1 || fromY > 1 || toY > 1 { if fromX > 1 || toX > 1 || fromY > 1 || toY > 1 {
return fromX, fromY, toX, toY, nil return fromX, fromY, toX, toY, nil
@@ -190,32 +190,43 @@ func (dExt *XTDriver) assertSelector(selector, assert string) error {
return nil 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 { switch check {
case option.SelectorOCR: case option.SelectorOCR:
err = dExt.assertOCR(expected, assert) err = dExt.assertOCR(expected, assert)
case option.SelectorAI: case option.SelectorAI:
_, err = dExt.AIAssert(expected) aiResult, err = dExt.AIAssert(expected)
case option.SelectorForegroundApp: case option.SelectorForegroundApp:
err = dExt.assertForegroundApp(expected, assert) err = dExt.assertForegroundApp(expected, assert)
case option.SelectorSelector: case option.SelectorSelector:
err = dExt.assertSelector(expected, assert) err = dExt.assertSelector(expected, assert)
default: default:
return fmt.Errorf("validator %s not implemented", check) return nil, fmt.Errorf("validator %s not implemented", check)
} }
if err != nil { if err != nil {
// Technical error (not assertion failure)
if message == nil { if message == nil {
message = []string{""} message = []string{""}
} }
log.Error().Err(err).Str("assert", assert).Str("expect", expected). log.Error().Err(err).Str("assert", assert).Str("expect", expected).
Str("msg", message[0]).Msg("validate failed") 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 { type SleepConfig struct {
+5 -5
View File
@@ -160,22 +160,22 @@ func (dev *IOSDevice) Setup() error {
if version.GreaterThan(semver.MustParse("17.4.0")) { if version.GreaterThan(semver.MustParse("17.4.0")) {
info, err := tunnel.TunnelInfoForDevice(dev.DeviceEntry.Properties.SerialNumber, ios.HttpApiHost(), ios.HttpApiPort()) info, err := tunnel.TunnelInfoForDevice(dev.DeviceEntry.Properties.SerialNumber, ios.HttpApiHost(), ios.HttpApiPort())
if err != nil { if err != nil {
return err return errors.Wrap(code.DeviceConnectionError, err.Error())
} }
dev.DeviceEntry.UserspaceTUNPort = info.UserspaceTUNPort dev.DeviceEntry.UserspaceTUNPort = info.UserspaceTUNPort
dev.DeviceEntry.UserspaceTUN = info.UserspaceTUN dev.DeviceEntry.UserspaceTUN = info.UserspaceTUN
rsdService, err := ios.NewWithAddrPortDevice(info.Address, info.RsdPort, dev.DeviceEntry) rsdService, err := ios.NewWithAddrPortDevice(info.Address, info.RsdPort, dev.DeviceEntry)
if err != nil { if err != nil {
return err return errors.Wrap(code.DeviceConnectionError, err.Error())
} }
defer rsdService.Close() defer rsdService.Close()
rsdProvider, err := rsdService.Handshake() rsdProvider, err := rsdService.Handshake()
if err != nil { if err != nil {
return err return errors.Wrap(code.DeviceConnectionError, err.Error())
} }
device, err := ios.GetDeviceWithAddress(dev.DeviceEntry.Properties.SerialNumber, info.Address, rsdProvider) device, err := ios.GetDeviceWithAddress(dev.DeviceEntry.Properties.SerialNumber, info.Address, rsdProvider)
if err != nil { if err != nil {
return err return errors.Wrap(code.DeviceConnectionError, err.Error())
} }
device.UserspaceTUN = dev.DeviceEntry.UserspaceTUN device.UserspaceTUN = dev.DeviceEntry.UserspaceTUN
device.UserspaceTUNPort = dev.DeviceEntry.UserspaceTUNPort device.UserspaceTUNPort = dev.DeviceEntry.UserspaceTUNPort
@@ -470,7 +470,7 @@ func (dev *IOSDevice) getVersion() (version *semver.Version, err error) {
version, err = ios.GetProductVersion(dev.DeviceEntry) version, err = ios.GetProductVersion(dev.DeviceEntry)
if err != nil { if err != nil {
log.Error().Err(err).Msg("failed to get version") 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") log.Info().Str("version", version.String()).Msg("get ios device version")
return version, nil return version, nil