Merge branch 'master' into session_refactor

This commit is contained in:
余泓铮
2025-07-04 19:57:51 +08:00
21 changed files with 770 additions and 43 deletions

View File

@@ -686,6 +686,270 @@ AIQuery 可能遇到的常见错误:
3. 建议在查询中使用具体、明确的描述以获得更好的结果
4. 对于复杂的信息提取,可以要求返回 JSON 格式的结构化数据
## StartToGoal 功能详解
### 概述
`StartToGoal` 是 HttpRunner v5 中的目标导向智能操作功能它使用自然语言描述目标然后自动规划和执行一系列操作来达成目标。该功能基于视觉语言模型VLM进行智能规划能够理解屏幕内容并自动生成操作序列。
### 功能特点
- **目标导向**: 使用自然语言描述最终目标AI 自动规划操作步骤
- **智能规划**: 基于屏幕内容进行上下文相关的操作规划
- **自动执行**: 自动执行规划的操作序列直到达成目标
- **灵活控制**: 支持多种控制选项如重试次数、超时时间等
### 基本用法
#### 1. 基本示例
```go
// 基本目标导向操作
results, err := driver.StartToGoal(ctx, "导航到设置页面并启用深色模式")
// 带选项的目标导向操作
results, err := driver.StartToGoal(ctx, "登录应用",
option.WithMaxRetryTimes(3),
option.WithIdentifier("user-login"),
)
```
#### 2. 在测试步骤中使用
```go
hrp.NewStep("Navigate to Settings").
Android().
StartToGoal("打开设置页面")
hrp.NewStep("Enable Feature").
Android().
StartToGoal("启用深色模式功能",
option.WithMaxRetryTimes(3),
option.WithIdentifier("enable-dark-mode"),
)
```
### TimeLimit 时间限制功能
`StartToGoal` 支持 `TimeLimit` 选项,用于设置执行时间限制。这是一个重要的资源管理功能。
#### 功能特性
- **时间限制**: 支持设置执行时间上限(秒)
- **优雅停止**: 超出时间限制后停止执行,但返回成功状态
- **部分结果**: 即使达到时间限制,也会返回已完成的规划结果
#### 使用方法
##### 基本用法
```go
// 设置 30 秒时间限制
results, err := driver.StartToGoal(ctx, prompt, option.WithTimeLimit(30))
```
##### 与其他选项结合使用
```go
results, err := driver.StartToGoal(ctx, prompt,
option.WithTimeLimit(45), // 45秒时间限制
option.WithMaxRetryTimes(3), // 最大重试3次
option.WithIdentifier("my-task"), // 任务标识符
)
```
#### TimeLimit vs Timeout
| 特性 | TimeLimit | Timeout | Interrupt Signal |
|------|-----------|---------|------------------|
| 行为 | 优雅停止 | 强制取消 | 立即中断 |
| 返回值 | 成功 (err == nil) | 错误 (err != nil) | 错误 (err != nil) |
| 结果 | 返回部分结果 | 返回部分结果 | 返回部分结果 |
| 用途 | 资源管理,时间预算 | 防止无限等待 | 用户主动中断 |
| 优先级 | 中等 | 低 | 最高 |
#### 使用场景
##### 使用 TimeLimit 的场景:
- 需要在指定时间内完成尽可能多的任务
- 资源管理和时间预算控制
- 希望获得部分结果而不是完全失败
- 测试场景下的时间控制
##### 使用 Timeout 的场景:
- 防止无限等待
- 超时即视为失败的场景
- 需要严格的时间控制
##### Interrupt Signal 的特点:
- 用户主动中断Ctrl+C
- 优先级最高,立即生效
- 无论是否设置 TimeLimit都返回错误
- 适用于需要立即停止的场景
#### 实现原理
1. **Context 复用**: `TimeLimit` 和 `Timeout` 复用相同的 context 超时机制
2. **模式标记**: 通过 `isTimeLimitMode` 标记区分当前是时间限制模式还是超时模式
3. **优先级处理**: 在 `ctx.Done()` 时按优先级检查取消原因
4. **结果收集**: 返回所有已完成的规划结果
**技术实现**
```go
// 复用 timeout context 机制,用标记区分模式
var isTimeLimitMode bool
if options.TimeLimit > 0 {
ctx, cancel = context.WithTimeout(ctx, time.Duration(options.TimeLimit)*time.Second)
isTimeLimitMode = true
} else if options.Timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, time.Duration(options.Timeout)*time.Second)
}
// 按优先级检查取消原因
select {
case <-ctx.Done():
cause := context.Cause(ctx)
// 1. 中断信号优先级最高,始终返回错误
if errors.Is(cause, code.InterruptError) {
return allPlannings, errors.Wrap(cause, "StartToGoal interrupted")
}
// 2. TimeLimit 超时返回成功
if isTimeLimitMode && errors.Is(cause, context.DeadlineExceeded) {
return allPlannings, nil
}
// 3. 其他取消原因返回错误
return allPlannings, errors.Wrap(cause, "StartToGoal cancelled")
}
```
#### 注意事项
1. **检测精度**: 时间限制的检测精度依赖于规划和工具调用的频率,基于 Go context 机制更加精确
2. **资源清理**: 即使达到时间限制,也会完成当前操作以确保资源正确清理
3. **结果可用性**: 返回的结果包含会话数据,可用于生成报告
4. **Context 复用**: `TimeLimit` 和 `Timeout` 复用相同的 context 超时机制,简化了实现
5. **优先级**: 如果同时设置了 `TimeLimit` 和 `Timeout``TimeLimit` 优先生效
6. **中断信号**: 用户中断信号(如 Ctrl+C优先级最高无论是否设置 `TimeLimit` 都会返回错误
### 支持的选项
`StartToGoal` 支持多种控制选项:
```go
// 全面的选项示例
results, err := driver.StartToGoal(ctx, prompt,
option.WithTimeLimit(60), // 时间限制(秒)
option.WithTimeout(120), // 超时时间(秒)
option.WithMaxRetryTimes(5), // 最大重试次数
option.WithIdentifier("task-id"), // 任务标识符
option.WithLLMService("gpt-4o"), // LLM 服务
option.WithCVService("vedem"), // CV 服务
option.WithResetHistory(true), // 重置对话历史
)
```
### 最佳实践
#### 1. 明确的目标描述
```go
// 好的示例:具体明确
StartToGoal("打开设置页面,找到显示选项,然后启用深色模式")
// 避免:过于模糊
StartToGoal("做一些设置")
```
#### 2. 合理的时间限制
```go
// 根据任务复杂度设置合理的时间限制
StartToGoal("完成用户注册流程", option.WithTimeLimit(120)) // 复杂任务
StartToGoal("点击登录按钮", option.WithTimeLimit(30)) // 简单任务
```
#### 3. 错误处理和重试
```go
// 设置重试机制
results, err := driver.StartToGoal(ctx, prompt,
option.WithMaxRetryTimes(3),
option.WithTimeLimit(90),
)
if err != nil {
// 处理错误
log.Printf("StartToGoal failed: %v", err)
// 可以分析 results 中的部分结果
}
```
### 实际应用场景
#### 1. 复杂的操作流程
```go
// 完成整个购物流程
hrp.NewStep("Complete Purchase").
Android().
StartToGoal("搜索商品'手机',选择第一个商品,添加到购物车,然后结账",
option.WithTimeLimit(180),
option.WithMaxRetryTimes(2),
)
```
#### 2. 应用初始化设置
```go
// 首次使用应用的设置流程
hrp.NewStep("Initial Setup").
Android().
StartToGoal("跳过引导页,允许所有权限,然后进入主界面",
option.WithTimeLimit(60),
)
```
#### 3. 测试场景验证
```go
// 验证特定功能流程
hrp.NewStep("Verify Feature").
Android().
StartToGoal("验证分享功能是否正常工作",
option.WithTimeLimit(45),
option.WithIdentifier("share-test"),
)
```
### 返回结果
`StartToGoal` 返回 `PlanningExecutionResult` 数组,包含详细的执行信息:
```go
type PlanningExecutionResult struct {
PlanningResult ai.PlanningResult `json:"planning_result"`
SubActions []*SubActionResult `json:"sub_actions"`
StartTime int64 `json:"start_time"`
Elapsed int64 `json:"elapsed"`
}
```
可以通过返回结果分析执行过程:
```go
results, err := driver.StartToGoal(ctx, prompt, option.WithTimeLimit(60))
if err != nil {
log.Printf("Task failed: %v", err)
}
// 分析执行结果
for i, result := range results {
log.Printf("Planning %d: %s", i+1, result.PlanningResult.Thought)
log.Printf("Actions executed: %d", len(result.SubActions))
log.Printf("Elapsed time: %d ms", result.Elapsed)
}
```
## 完整示例
以下是一个完整的 AIQuery 使用示例:
@@ -714,4 +978,43 @@ func TestAIQuery(t *testing.T) {
err := hrp.NewRunner(t).Run(testCase)
assert.Nil(t, err)
}
```
## StartToGoal 完整示例
以下是 `StartToGoal` 功能的完整使用示例:
```go
func TestStartToGoal(t *testing.T) {
testCase := &hrp.TestCase{
Config: hrp.NewConfig("StartToGoal Demo").
SetLLMService(option.OPENAI_GPT_4O),
TestSteps: []hrp.IStep{
hrp.NewStep("App Launch").
Android().
AppLaunch("com.example.app"),
hrp.NewStep("Complete User Setup").
Android().
StartToGoal("跳过引导页,创建新用户账户",
option.WithTimeLimit(120),
option.WithMaxRetryTimes(3),
),
hrp.NewStep("Navigate to Feature").
Android().
StartToGoal("导航到设置页面并启用深色模式",
option.WithTimeLimit(60),
option.WithIdentifier("enable-dark-mode"),
),
hrp.NewStep("Complex Workflow").
Android().
StartToGoal("搜索'测试',选择第一个结果,然后分享给朋友",
option.WithTimeLimit(180),
option.WithMaxRetryTimes(2),
),
},
}
err := hrp.NewRunner(t).Run(testCase)
assert.Nil(t, err)
}
```

View File

@@ -36,7 +36,7 @@
"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
"time_limit": 300
}
}
]

View File

@@ -7,13 +7,14 @@ import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
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"
"github.com/httprunner/httprunner/v5/uixt/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGameLianliankan(t *testing.T) {
@@ -45,7 +46,7 @@ func TestGameLianliankan(t *testing.T) {
AssertAI("当前位于抖音「连了又连」小游戏页面"),
hrp.NewStep("开始游戏").
Android().
StartToGoal(userInstruction, option.WithMaxRetryTimes(100)),
StartToGoal(userInstruction, option.WithTimeLimit(300)),
},
}
err := testCase.Dump2JSON("game_llk.json")

View File

@@ -65,7 +65,7 @@
"method": "start_to_goal",
"params": "浪漫餐厅是一款经营类游戏,以下是游戏的基本规则说明:\n1、点击右下角锅铲开始任务\n2、将棋子拖拽至相同棋子可升级生成新棋子\n3、拖拽相同棋子时被部分遮挡的棋子只能作为拖拽终点不能作为拖拽起点\n4、当游戏界面中没有相同棋子时可以点击游戏页面中央的购物袋生成新的棋子\n5、若不知道如何操作请按照游戏指引进行游玩\n\n请严格按照以上游戏规则开始游戏\n",
"options": {
"timeout": 300,
"time_limit": 300,
"pre_mark_operation": true
}
}

View File

@@ -42,7 +42,7 @@ func TestGameRomanticRestaurant(t *testing.T) {
Android().
StartToGoal(userInstruction,
option.WithPreMarkOperation(true),
option.WithTimeout(300)), // 5 minutes
option.WithTimeLimit(300)), // 5 minutes
hrp.NewStep("退出抖音 app").
Android().
AppTerminate("$package_name"),

View File

@@ -65,7 +65,7 @@
"method": "start_to_goal",
"params": "每天数独是一款逻辑推理游戏,玩家需要通过推理来确定黄色方块的所在位置,以下是游戏的基本规则说明:\n1、方块外面的数字代表所在那一行或一列的黄色方块数量。\n2、初始状态为白色方块选择正确后变为黄色方块选择错误后变为红底的 X。\n3、如果同一行或列有两个数字则至少需要一个白底 X 分割它们作为间隔。\n4、如果数字与格子最大数相同时该列或行必然全都是黄色方块。\n5、只能点击白色方块不要重复点击同一个方块。\n\n请严格按照以上游戏规则开始游戏\n",
"options": {
"timeout": 300,
"time_limit": 300,
"pre_mark_operation": true
}
}

View File

@@ -42,7 +42,7 @@ func TestGameSudoku(t *testing.T) {
Android().
StartToGoal(userInstruction,
option.WithPreMarkOperation(true),
option.WithTimeout(300)), // 5 minutes
option.WithTimeLimit(300)), // 5 minutes
hrp.NewStep("退出抖音 app").
Android().
AppTerminate("$package_name"),

View File

@@ -65,7 +65,7 @@
"method": "start_to_goal",
"params": "羊了个羊是一款热门的消除类小游戏,玩法简单但具有挑战性。以下是游戏的基本规则说明:\n1. 游戏目标: 玩家需要通过消除图案来完成关卡,最终目标是清空所有图案。\n2. 消除规则:\n- 游戏界面中会出现多个图案,玩家需要点击图案将其放入底部的槽中。\n- 图案存在多层堆叠的情况,只能点击最上层的完整图案。\n- 当槽中有三个相同的图案时,这三个图案会自动消除。\n- 玩家需要尽量避免槽中积累过多不同的图案,以免无法继续消除。\n- 严禁点击收集槽里的图案,严禁观看广告和使用道具(移出、撤回、洗牌)。\n- 请持续推进游戏进程,游戏通关后继续下一关,游戏失败后重新开始。\n3. 游戏界面: 图案通常以堆叠的方式呈现,玩家需要逐层消除。\n4. 关卡设计: 游戏包含多个关卡,随着关卡的推进,图案的复杂度和数量会增加。\n5. 策略性: 玩家需要规划消除顺序,以避免槽中积累过多无法消除的图案。\n\n请严格按照以上游戏规则开始游戏\n",
"options": {
"timeout": 300,
"time_limit": 300,
"pre_mark_operation": true
}
}

View File

@@ -49,7 +49,7 @@ func TestGameYanglegeyang(t *testing.T) {
Android().
StartToGoal(userInstruction,
option.WithPreMarkOperation(true),
option.WithTimeout(300)), // 5 minutes
option.WithTimeLimit(300)), // 5 minutes
hrp.NewStep("退出抖音 app").
Android().
AppTerminate("$package_name"),

View File

@@ -0,0 +1,88 @@
{
"config": {
"name": "跃动小子小游戏自动化测试",
"variables": {
"package_name": "com.ss.android.ugc.aweme"
},
"ai_options": {
"llm_service": "doubao-1.5-thinking-vision-pro-250428"
}
},
"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": "start_to_goal",
"params": "搜索「跃动小子」,启动小游戏",
"options": {
"pre_mark_operation": true
}
}
]
},
"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、请持续推进游戏进程。\n3、屏幕底部的黑白按钮不要进行点击操作。\n\n请严格按照以上游戏规则开始游戏\n",
"options": {
"time_limit": 300,
"pre_mark_operation": true
}
}
]
}
},
{
"name": "退出抖音 app",
"android": {
"os_type": "android",
"actions": [
{
"method": "app_terminate",
"params": "$package_name"
}
]
}
}
]
}

View File

@@ -0,0 +1,55 @@
package game_yuedongxiaozi
import (
"testing"
"github.com/stretchr/testify/require"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/uixt/option"
)
func TestGameZhuadaE(t *testing.T) {
userInstruction := `跃动小子是一款开宝箱类的小游戏,以下是游戏的基本规则说明:
1、打开宝箱按照游戏指引进行「出售」或「装备」操作。
2、请持续推进游戏进程。
3、屏幕底部的黑白按钮不要进行点击操作。
请严格按照以上游戏规则,开始游戏
`
testCase := &hrp.TestCase{
Config: hrp.NewConfig("跃动小子小游戏自动化测试").
SetLLMService(option.DOUBAO_1_5_THINKING_VISION_PRO_250428).
WithVariables(map[string]interface{}{
"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("启动「跃动小子」小游戏").
Android().
StartToGoal("搜索「跃动小子」,启动小游戏",
option.WithPreMarkOperation(true)).
Validate().
AssertAI("当前页面底部包含「领地」「试炼」按钮"),
hrp.NewStep("开始游戏").
Android().
StartToGoal(userInstruction,
option.WithPreMarkOperation(true),
option.WithTimeLimit(300)), // 5 minutes
hrp.NewStep("退出抖音 app").
Android().
AppTerminate("$package_name"),
},
}
err := testCase.Dump2JSON("game_yuedongxiaozi.json")
require.Nil(t, err)
// err = hrp.NewRunner(t).Run(testCase)
// assert.Nil(t, err)
}

View File

@@ -88,7 +88,7 @@
"method": "start_to_goal",
"params": "抓大鹅是一款抓取类小游戏,以下是游戏的基本规则说明:\n1. 游戏目标: 玩家需要通过抓取图案来完成关卡,最终目标是清空所有图案。\n2. 抓取规则:\n- 游戏界面中会出现多个图案,图案存在多层堆叠的情况,玩家需要点击图案将其抓取放入到槽中。\n- 当抓取到三个相同的图案放入抓取槽时,这三个图案会成功消除。\n- 需要尽量避免抓取槽满的情况,抓取槽满时游戏失败。\n- 游戏通关后继续进入下一关,游戏失败后重新开始游戏。\n\n请严格按照以上游戏规则开始游戏\n",
"options": {
"timeout": 300,
"time_limit": 300,
"pre_mark_operation": true
}
}

View File

@@ -50,7 +50,7 @@ func TestGameZhuadaE(t *testing.T) {
Android().
StartToGoal(userInstruction,
option.WithPreMarkOperation(true),
option.WithTimeout(300)), // 5 minutes
option.WithTimeLimit(300)), // 5 minutes
hrp.NewStep("退出抖音 app").
Android().
AppTerminate("$package_name"),

View File

@@ -0,0 +1,170 @@
{
"config": {
"name": "验证 UIA2 打点数据准确性",
"variables": {
"app_name": "抖音"
},
"android": [
{
"log_on": true,
"adb_server_host": "localhost",
"adb_server_port": 5037,
"uia2_ip": "localhost",
"uia2_port": 6790,
"uia2_server_package_name": "io.appium.uiautomator2.server",
"uia2_server_test_package_name": "io.appium.uiautomator2.server.test"
}
]
},
"teststeps": [
{
"name": "启动抖音",
"android": {
"os_type": "android",
"actions": [
{
"method": "home"
},
{
"method": "app_terminate",
"params": "com.ss.android.ugc.aweme"
},
{
"method": "swipe_to_tap_app",
"params": "$app_name",
"options": {
"identifier": "启动抖音",
"max_retry_times": 5,
"pre_mark_operation": true
}
},
{
"method": "sleep",
"params": 5
}
]
},
"validate": [
{
"check": "ui_ocr",
"assert": "exists",
"expect": "推荐",
"msg": "抖音启动失败,「推荐」不存在"
}
]
},
{
"name": "处理青少年弹窗",
"android": {
"os_type": "android",
"actions": [
{
"method": "tap_ocr",
"params": "我知道了",
"options": {
"ignore_NotFoundError": true
}
}
]
}
},
{
"name": "进入推荐页",
"android": {
"os_type": "android",
"actions": [
{
"method": "tap_ocr",
"params": "推荐",
"options": {
"identifier": "点击推荐",
"offset": [
0,
-1
],
"pre_mark_operation": true
}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "向上滑动 2 次",
"android": {
"os_type": "android",
"actions": [
{
"method": "swipe_direction",
"params": "up",
"options": {
"identifier": "第 1 次上划",
"pre_mark_operation": true
}
},
{
"method": "sleep",
"params": 2
},
{
"method": "swipe_direction",
"params": "up",
"options": {
"identifier": "第 2 次上划",
"pre_mark_operation": true
}
},
{
"method": "sleep",
"params": 2
},
{
"method": "swipe_direction",
"params": "up",
"options": {
"identifier": "第 3 次上划",
"pre_mark_operation": true
}
},
{
"method": "sleep",
"params": 2
},
{
"method": "tap_xy",
"params": [
0.9,
0.1
],
"options": {
"identifier": "点击进入搜索框",
"pre_mark_operation": true
}
},
{
"method": "sleep",
"params": 2
},
{
"method": "input",
"params": "httprunner 发版记录",
"options": {
"identifier": "输入搜索关键词",
"pre_mark_operation": true
}
},
{
"method": "tap_ocr",
"params": "搜索",
"options": {
"identifier": "点击搜索"
}
}
]
}
}
]
}

View File

@@ -0,0 +1,60 @@
//go:build localtest
package uitest
import (
"testing"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/uixt/option"
)
func TestUIA2Log(t *testing.T) {
testCase := &hrp.TestCase{
Config: hrp.NewConfig("验证 UIA2 打点数据准确性").
WithVariables(map[string]interface{}{
"app_name": "抖音",
}).
SetAndroid(
option.WithAdbLogOn(true),
),
TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音").
Android().
Home().
AppTerminate("com.ss.android.ugc.aweme"). // 关闭已运行的抖音
SwipeToTapApp("$app_name",
option.WithMaxRetryTimes(5),
option.WithIdentifier("启动抖音"),
option.WithPreMarkOperation(true)).Sleep(5).
Validate().
AssertOCRExists("推荐", "抖音启动失败,「推荐」不存在"),
hrp.NewStep("处理青少年弹窗").
Android().
TapByOCR("我知道了",
option.WithIgnoreNotFoundError(true)),
hrp.NewStep("进入推荐页").
Android().TapByOCR("推荐",
option.WithIdentifier("点击推荐"),
option.WithPreMarkOperation(true),
option.WithTapOffset(0, -1)).Sleep(5),
hrp.NewStep("向上滑动 2 次").
Android().
SwipeUp(option.WithIdentifier("第 1 次上划"), option.WithPreMarkOperation(true)).Sleep(2).
SwipeUp(option.WithIdentifier("第 2 次上划"), option.WithPreMarkOperation(true)).Sleep(2).
SwipeUp(option.WithIdentifier("第 3 次上划"), option.WithPreMarkOperation(true)).Sleep(2).
TapXY(0.9, 0.1, option.WithIdentifier("点击进入搜索框"), option.WithPreMarkOperation(true)).Sleep(2).
Input("httprunner 发版记录", option.WithIdentifier("输入搜索关键词"), option.WithPreMarkOperation(true)).
TapByOCR("搜索", option.WithIdentifier("点击搜索")),
},
}
if err := testCase.Dump2JSON("demo_android_uia2_log.json"); err != nil {
t.Fatal(err)
}
err := hrp.Run(t, testCase)
if err != nil {
t.Fatal(err)
}
}

View File

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

View File

@@ -711,27 +711,27 @@ func (r *SessionRunner) Start(givenVars map[string]interface{}) (summary *TestCa
}
}
// TODO: move to mobile ui step
// Collect logs from cached drivers
for _, cached := range uixt.ListCachedDrivers() {
// add WDA/UIA logs to summary
client := cached.Item
if !client.GetDevice().LogEnabled() {
continue
}
// add WDA/UIA2 logs to summary
logs := map[string]interface{}{
"uuid": cached.Key,
}
client := cached.Item
if client.GetDevice().LogEnabled() {
log, err1 := client.StopCaptureLog()
if err1 != nil {
if err == nil {
err = errors.Wrap(err1, "stop capture log failed")
} else {
err = errors.Wrap(err, "stop capture log failed")
}
return
log, err1 := client.StopCaptureLog()
if err1 != nil {
if err == nil {
err = errors.Wrap(err1, "stop capture log failed")
} else {
err = errors.Wrap(err, "stop capture log failed")
}
logs["content"] = log
return
}
logs["content"] = log
summary.Logs = append(summary.Logs, logs)
}

View File

@@ -23,7 +23,7 @@ func TestGetImageFromBuffer(t *testing.T) {
require.Nil(t, err)
cvResult, err := service.ReadFromBuffer(buf)
assert.Nil(t, err)
fmt.Println(fmt.Sprintf("cvResult: %v", cvResult))
fmt.Printf("cvResult: %v\n", cvResult)
}
func TestGetImageFromPath(t *testing.T) {
@@ -32,5 +32,5 @@ func TestGetImageFromPath(t *testing.T) {
require.Nil(t, err)
cvResult, err := service.ReadFromPath(imagePath)
assert.Nil(t, err)
fmt.Println(fmt.Sprintf("cvResult: %v", cvResult))
fmt.Printf("cvResult: %v\n", cvResult)
}

View File

@@ -21,18 +21,24 @@ func (dExt *XTDriver) StartToGoal(ctx context.Context, prompt string, opts ...op
if options.MaxRetryTimes > 0 {
logger = logger.Int("max_retry_times", options.MaxRetryTimes)
}
if options.Timeout > 0 {
logger = logger.Int("timeout_seconds", options.Timeout)
}
logger.Msg("StartToGoal")
// Create timeout context for entire StartToGoal process if Timeout is specified
if options.Timeout > 0 {
// Handle TimeLimit and Timeout with unified context mechanism
var isTimeLimitMode bool
if options.TimeLimit > 0 {
// TimeLimit takes precedence over Timeout
logger = logger.Int("time_limit_seconds", options.TimeLimit)
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(options.TimeLimit)*time.Second)
defer cancel()
isTimeLimitMode = true
} else if options.Timeout > 0 {
// Use Timeout only if TimeLimit is not set
logger = logger.Int("timeout_seconds", options.Timeout)
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(options.Timeout)*time.Second)
defer cancel()
log.Info().Int("timeout_seconds", options.Timeout).Msg("StartToGoal timeout configured for entire process")
}
logger.Msg("StartToGoal")
var allPlannings []*PlanningExecutionResult
var attempt int
@@ -40,16 +46,26 @@ func (dExt *XTDriver) StartToGoal(ctx context.Context, prompt string, opts ...op
attempt++
log.Info().Int("attempt", attempt).Msg("planning attempt")
// Check for context cancellation (interrupt signal or timeout)
// Check for context cancellation (timeout, time limit, or interrupt)
select {
case <-ctx.Done():
cause := context.Cause(ctx)
// Handle TimeLimit timeout - return success
if isTimeLimitMode && errors.Is(cause, context.DeadlineExceeded) {
log.Info().
Int("attempt", attempt).
Int("completed_plannings", len(allPlannings)).
Int("time_limit_seconds", options.TimeLimit).
Msg("StartToGoal time limit reached, stopping gracefully")
return allPlannings, nil
}
// Handle other cancellations (Timeout, interrupt, external cancellation) - return error
log.Warn().
Int("attempt", attempt).
Int("completed_plannings", len(allPlannings)).
Err(cause).
Msg("StartToGoal cancelled")
// Return the specific error type based on the cancellation cause
return allPlannings, errors.Wrap(cause, "StartToGoal cancelled")
default:
}
@@ -99,10 +115,25 @@ func (dExt *XTDriver) StartToGoal(ctx context.Context, prompt string, opts ...op
// Invoke tool calls
for _, toolCall := range planningResult.ToolCalls {
// Check for context cancellation before each action
// Check for context cancellation (timeout, time limit, or interrupt) before each action
select {
case <-ctx.Done():
cause := context.Cause(ctx)
// Handle TimeLimit timeout - return success
if isTimeLimitMode && errors.Is(cause, context.DeadlineExceeded) {
log.Info().
Int("attempt", attempt).
Int("completed_plannings", len(allPlannings)).
Int("completed_tool_calls", len(planningResult.SubActions)).
Int("total_tool_calls", len(planningResult.ToolCalls)).
Int("time_limit_seconds", options.TimeLimit).
Msg("StartToGoal time limit reached during tool call execution, stopping gracefully")
planningResult.Elapsed = time.Since(planningStartTime).Milliseconds()
allPlannings = append(allPlannings, planningResult)
return allPlannings, nil
}
// Handle other cancellations (Timeout, external cancellation) - return error
log.Warn().
Int("attempt", attempt).
Int("completed_plannings", len(allPlannings)).
@@ -112,7 +143,6 @@ func (dExt *XTDriver) StartToGoal(ctx context.Context, prompt string, opts ...op
Msg("invokeToolCalls cancelled")
planningResult.Elapsed = time.Since(planningStartTime).Milliseconds()
allPlannings = append(allPlannings, planningResult)
// Return the specific error type based on the cancellation cause
return allPlannings, errors.Wrap(cause, "invokeToolCalls cancelled")
default:
}

View File

@@ -98,6 +98,12 @@ func (dExt *XTDriver) SwipeToTapTexts(texts []string, opts ...option.ActionOptio
log.Info().Strs("texts", texts).Msg("swipe to tap texts")
opts = append(opts, option.WithMatchOne(true), option.WithRegex(true))
// Remove identifier for swipe operations to avoid WDA/UIA2 logging
actionOptions := option.NewActionOptions(opts...)
actionOptions.Identifier = ""
optionsWithoutIdentifier := actionOptions.Options()
var point ai.PointF
findTexts := func(d *XTDriver) error {
var err error
@@ -129,7 +135,7 @@ func (dExt *XTDriver) SwipeToTapTexts(texts []string, opts ...option.ActionOptio
return d.TapAbsXY(point.X, point.Y, opts...)
}
findAction := prepareSwipeAction(dExt, nil, opts...)
findAction := prepareSwipeAction(dExt, nil, optionsWithoutIdentifier...)
return dExt.LoopUntil(findAction, findTexts, foundTextAction, opts...)
}
@@ -146,15 +152,19 @@ func (dExt *XTDriver) SwipeToTapApp(appName string, opts ...option.ActionOption)
log.Error().Err(err).Msg("auto handle popup failed")
}
// Remove identifier for swipe operations to avoid WDA/UIA2 logging
actionOptions := option.NewActionOptions(opts...)
actionOptions.Identifier = ""
optionsWithoutIdentifier := actionOptions.Options()
// swipe to first screen
for i := 0; i < 5; i++ {
dExt.Swipe(0.5, 0.5, 0.9, 0.5, opts...)
dExt.Swipe(0.5, 0.5, 0.9, 0.5, optionsWithoutIdentifier...)
}
opts = append(opts, option.WithDirection("left"))
opts = append(opts, option.WithMaxRetryTimes(5))
actionOptions := option.NewActionOptions(opts...)
// tap app icon above the text
if len(actionOptions.TapOffset) == 0 {
opts = append(opts, option.WithTapOffset(0, -100))

View File

@@ -201,6 +201,7 @@ type ActionOptions struct {
Steps int `json:"steps,omitempty" yaml:"steps,omitempty" desc:"Number of steps for action"`
Direction interface{} `json:"direction,omitempty" yaml:"direction,omitempty" desc:"Direction for swipe operations or custom coordinates"`
Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty" desc:"Timeout in seconds for action execution"`
TimeLimit int `json:"time_limit,omitempty" yaml:"time_limit,omitempty" desc:"Time limit in seconds for action execution, stops gracefully when reached"`
Frequency int `json:"frequency,omitempty" yaml:"frequency,omitempty" desc:"Action frequency"`
ScreenOptions
@@ -281,6 +282,9 @@ func (o *ActionOptions) Options() []ActionOption {
if o.Timeout != 0 {
options = append(options, WithTimeout(o.Timeout))
}
if o.TimeLimit != 0 {
options = append(options, WithTimeLimit(o.TimeLimit))
}
if o.Frequency != 0 {
options = append(options, WithFrequency(o.Frequency))
}
@@ -562,6 +566,12 @@ func WithTimeout(seconds int) ActionOption {
}
}
func WithTimeLimit(seconds int) ActionOption {
return func(o *ActionOptions) {
o.TimeLimit = seconds
}
}
func WithIgnoreNotFoundError(ignoreError bool) ActionOption {
return func(o *ActionOptions) {
o.IgnoreNotFoundError = ignoreError