merge master

This commit is contained in:
余泓铮
2025-07-09 15:53:52 +08:00
19 changed files with 325 additions and 189 deletions

View File

@@ -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

View File

@@ -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("当前位于游戏界面"),

View File

@@ -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、只能点击白色方块不要重复点击同一个方块。\n\n请严格按照以上游戏规则开始游戏\n",
"params": "每天数独是一款逻辑推理游戏,玩家需要通过推理来确定黄色方块的所在位置,以下是游戏的基本规则说明:\n1、方块外面的数字代表所在那一行或一列的黄色方块数量。\n2、初始状态为白色方块选择正确后变为黄色方块选择错误后变为红底的 X。\n3、如果同一行或列有两个数字则至少需要一个白底 X 分割它们作为间隔。\n4、如果数字与格子最大数相同时该列或行必然全都是黄色方块。\n5、只能点击白色方块不要重复点击同一个方块。\n6、若出现「桌面入口」弹窗则直接关闭。\n7、若游戏失败弹出恢复血量的弹窗请关闭弹窗重新开始游戏。\n\n请严格按照以上游戏规则开始游戏\n",
"options": {
"time_limit": 300,
"pre_mark_operation": true

View File

@@ -10,12 +10,20 @@ import (
)
func TestGameSudoku(t *testing.T) {
startGameInstruction := `在手机桌面点击「每天数独」启动小游戏,等待游戏加载完成
1、点击【开始】按钮开始游戏进入数独的棋盘界面
2、若提示「体力不足」可通过观看广告免费获得体力观看完成后继续开始游戏
3、进入棋盘界面后即算作目标达成
`
userInstruction := `每天数独是一款逻辑推理游戏,玩家需要通过推理来确定黄色方块的所在位置,以下是游戏的基本规则说明:
1、方块外面的数字代表所在那一行或一列的黄色方块数量。
2、初始状态为白色方块选择正确后变为黄色方块选择错误后变为红底的 X。
3、如果同一行或列有两个数字则至少需要一个白底 X 分割它们作为间隔。
4、如果数字与格子最大数相同时该列或行必然全都是黄色方块。
5、只能点击白色方块不要重复点击同一个方块。
6、若出现「桌面入口」弹窗则直接关闭。
7、若游戏失败弹出恢复血量的弹窗请关闭弹窗重新开始游戏。
请严格按照以上游戏规则,开始游戏
`
@@ -26,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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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
}
]
},

View File

@@ -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("开始游戏").

View File

@@ -1 +1 @@
v5.0.0-250704
v5.0.0-250709

View File

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

149
report.go
View File

@@ -1838,6 +1838,101 @@ const htmlTemplate = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
{{if and $validator.msg (ne $validator.check_result "pass")}}
<div class="validator-message">{{$validator.msg}}</div>
{{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>
{{end}}
</div>

View File

@@ -1042,7 +1042,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)

View File

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

View File

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

View File

@@ -169,7 +169,7 @@ func (dExt *XTDriver) StartToGoal(ctx context.Context, prompt string, opts ...op
log.Error().Err(err).
Str("action", toolCall.Function.Name).
Msg("invoke tool call failed")
subActionResult.Error = err
subActionResult.Error = err.Error()
return err
}
return nil
@@ -371,7 +371,6 @@ func (dExt *XTDriver) executeAIAssert(assertion string, screenResult *ScreenResu
if !result.Pass {
assertResult.Error = result.Thought
return assertResult, errors.New(result.Thought)
}
return assertResult, nil
@@ -563,7 +562,7 @@ type SubActionResult struct {
Arguments interface{} `json:"arguments,omitempty"` // arguments passed to the sub-action
StartTime int64 `json:"start_time"` // sub-action start time
Elapsed int64 `json:"elapsed_ms"` // sub-action elapsed time(ms)
Error error `json:"error,omitempty"` // sub-action execution result
Error string `json:"error,omitempty"` // sub-action execution result
SessionData
}

View File

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

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) (
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 {

View File

@@ -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

View File

@@ -75,9 +75,9 @@ func (t *ToolSleep) Implement() server.ToolHandlerFunc {
case <-time.After(duration):
// Normal completion
case <-ctx.Done():
// Interrupted by context cancellation (e.g., CTRL+C)
log.Warn().Msg("sleep interrupted by cancellation")
return nil, fmt.Errorf("sleep interrupted: %w", ctx.Err())
// Interrupted by context cancellation (interrupt signal, timeout, time limit)
log.Info().Msg("sleep interrupted by context cancellation")
// Don't return error - let the upper layer handle timeout/time limit logic
}
message := fmt.Sprintf("Successfully slept for %v seconds", actualSeconds)
@@ -157,9 +157,9 @@ func (t *ToolSleepMS) Implement() server.ToolHandlerFunc {
case <-time.After(duration):
// Normal completion
case <-ctx.Done():
// Interrupted by context cancellation (e.g., CTRL+C)
log.Warn().Msg("sleep interrupted by cancellation")
return nil, fmt.Errorf("sleep interrupted: %w", ctx.Err())
// Interrupted by context cancellation (interrupt signal, timeout, time limit)
log.Info().Msg("sleep interrupted by context cancellation")
// Don't return error - let the upper layer handle timeout/time limit logic
}
message := fmt.Sprintf("Successfully slept for %d milliseconds", actualMilliseconds)