Merge branch 'pasteboard' of https://code.byted.org/iesqa/httprunner into pasteboard

This commit is contained in:
余泓铮
2025-08-11 21:24:23 +08:00
59 changed files with 1595 additions and 1585 deletions

View File

@@ -2,11 +2,11 @@ name: Claude Code
on:
issue_comment:
types: [created]
types: [created, edited]
pull_request_review_comment:
types: [created]
types: [created, edited]
issues:
types: [opened, assigned]
types: [opened, assigned, edited]
pull_request_review:
types: [submitted]

View File

@@ -24,8 +24,16 @@ jobs:
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- name: Install Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Checkout code
uses: actions/checkout@v2
- name: Install Python dependencies
run: |
python3 -m pip install --upgrade pip
python3 -m pip install funppy httprunner
- name: Build hrp binary
run: make build
- name: Run smoketest - run with parameters

View File

@@ -23,8 +23,14 @@ jobs:
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- name: Install Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Install Python plugin dependencies
run: python3 -m pip install funppy
run: |
python3 -m pip install --upgrade pip
python3 -m pip install funppy httprunner
- name: Checkout code
uses: actions/checkout@v2
- name: Run coverage

4
.gitignore vendored
View File

@@ -45,3 +45,7 @@ dist
*.egg-info
.python-version
.pytest_cache
# generated go module files in templates
internal/scaffold/templates/plugin/go.mod
internal/scaffold/templates/plugin/go.sum

128
CLAUDE.md Normal file
View File

@@ -0,0 +1,128 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
HttpRunner v5 is a comprehensive testing framework written in Go that supports API testing, load testing, and UI automation across multiple platforms (Android/iOS/Harmony/Browser). The framework integrates LLM technology for intelligent test automation and uses a pure visual-driven approach (OCR/CV/VLM) for UI testing.
## Development Commands
### Building
- `make build` - Build the hrp CLI tool with static linking and embedded version info
- `go build -o output/hrp ./cmd/cli` - Alternative build command
- `make test` - Run unit tests with race detection
### Testing
- `go test -race -v ./...` - Run all tests with race detection
- `go test -v ./tests/...` - Run test suite only
- `go test -v ./uixt/...` - Run UI automation tests
- `go test -v ./cmd/...` - Run CLI command tests
### Code Quality
- `go mod tidy` - Clean up dependencies
- `gofmt -w .` - Format code
- Pre-commit hooks are available in `scripts/` directory
## Core Architecture
### Main Components
**Core Testing Engine**
- `runner.go` - Main test runner (HRPRunner, CaseRunner, SessionRunner)
- `testcase.go` - Test case definitions and loading (ITestCase interface)
- `step.go` - Step definitions and configurations
- `step_*.go` - Specific step implementations (request, api, testcase, ui, etc.)
**Step Types**
- `step_request.go` - HTTP/HTTPS requests
- `step_api.go` - API calls with parameters
- `step_testcase.go` - Nested test cases
- `step_websocket.go` - WebSocket communication
- `step_ui.go` - UI automation steps
- `step_transaction.go` - Transaction grouping
- `step_rendezvous.go` - Synchronization points
- `step_shell.go` - Shell command execution
- `step_function.go` - Custom function calls
**UI Automation (uixt/)**
- `device.go` - Device abstraction interface (IDevice)
- `driver.go` - Driver interface and session management
- `android_*.go` - Android platform implementation (ADB/UIAutomator2)
- `ios_*.go` - iOS platform implementation (WDA)
- `harmony_*.go` - HarmonyOS implementation (HDC)
- `browser_*.go` - Web browser automation
- `ai/` - AI-powered UI interaction (OCR/VLM)
**CLI Interface (cmd/)**
- `root.go` - Root command and global configuration
- `run.go` - Test execution
- `server.go` - HTTP server mode
- `convert.go` - Format conversion utilities
- `build.go` - Plugin building
- `adb/` - Android device management
- `ios/` - iOS device management
### Plugin System
The framework supports both Go and Python plugins:
- `build.go` - Plugin compilation system
- `plugin.go` - Plugin interface definitions
- Templates in `internal/scaffold/templates/plugin/`
### Configuration Management
- `config.go` - Global configuration
- `internal/config/` - Environment and settings management
- Environment variables and .env file support
## Key Design Patterns
### Interface-Driven Architecture
- `ITestCase` interface for different test case sources
- `IDevice` interface for multi-platform support
- `IDriver` interface for different automation drivers
### Step-Based Testing
- Each test consists of configurable steps
- Steps support setup/teardown hooks
- Variables and parameters flow between steps
### Plugin Architecture
- Hashicorp go-plugin for Go plugins
- Python plugin support via funplugin
- Template-based plugin generation
## Testing Approach
### Test Formats Supported
- YAML/JSON test cases
- Go test files
- Python pytest integration
- HAR, Postman, cURL conversion
### UI Testing Strategy
- Pure visual-driven (no element locators)
- OCR/VLM for text recognition
- Cross-platform unified API
- AI-powered interaction planning
## Development Guidelines
### Code Structure
- Core framework logic in root directory
- Platform-specific implementations in `uixt/`
- CLI commands in `cmd/`
- Internal utilities in `internal/`
- Examples in `examples/`
### Dependencies
- Go 1.23+ required
- Uses Cobra for CLI
- Integrates with multiple automation frameworks
- LLM integration via CloudWeGo Eino
### Build Configuration
- Static linking for deployment
- Version info embedded via ldflags
- Cross-platform builds supported

View File

@@ -63,4 +63,4 @@ Copyright © 2017-present debugtalk. Apache-2.0 License.
* [hrp startproject](hrp_startproject.md) - Create a scaffold project
* [hrp wiki](hrp_wiki.md) - visit https://httprunner.com
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -23,4 +23,4 @@ simple utils for android device management
* [hrp adb install](hrp_adb_install.md) - push package to the device and install them automatically
* [hrp adb screencap](hrp_adb_screencap.md) - Start android screen capture
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -24,4 +24,4 @@ hrp adb devices [flags]
* [hrp adb](hrp_adb.md) - simple utils for android device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -28,4 +28,4 @@ hrp adb install [flags] PACKAGE
* [hrp adb](hrp_adb.md) - simple utils for android device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -25,4 +25,4 @@ hrp adb screencap [flags]
* [hrp adb](hrp_adb.md) - simple utils for android device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -36,4 +36,4 @@ hrp build $path ... [flags]
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -34,4 +34,4 @@ hrp convert $path... [flags]
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -29,4 +29,4 @@ simple utils for ios device management
* [hrp ios uninstall](hrp_ios_uninstall.md) - uninstall package automatically
* [hrp ios xctest](hrp_ios_xctest.md) - run xctest
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -26,4 +26,4 @@ hrp ios apps [flags]
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -24,4 +24,4 @@ hrp ios devices [flags]
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -25,4 +25,4 @@ hrp ios install [flags] PACKAGE
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -28,4 +28,4 @@ hrp ios mount [flags]
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -26,4 +26,4 @@ hrp ios ps [flags]
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -25,4 +25,4 @@ hrp ios reboot [flags]
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -24,4 +24,4 @@ hrp ios tunnel [flags]
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -26,4 +26,4 @@ hrp ios uninstall [flags] PACKAGE
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -28,4 +28,4 @@ hrp ios xctest [flags]
* [hrp ios](hrp_ios.md) - simple utils for ios device management
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -28,4 +28,4 @@ hrp mcp-server [flags]
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -31,4 +31,4 @@ hrp mcphost [flags]
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -24,4 +24,4 @@ hrp pytest $path ... [flags]
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -33,4 +33,4 @@ hrp report [result_folder] [flags]
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -46,4 +46,4 @@ hrp run $path... [flags]
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -30,4 +30,4 @@ hrp server start [flags]
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -29,4 +29,4 @@ hrp startproject $project_name [flags]
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -24,4 +24,4 @@ hrp wiki [flags]
* [hrp](hrp.md) - All-in-One Testing Framework for API, UI and Performance
###### Auto generated by spf13/cobra on 28-Jun-2025
###### Auto generated by spf13/cobra on 3-Aug-2025

View File

@@ -34,6 +34,15 @@ type Dimensions struct {
type Element struct {
Type string `json:"type"` // Element type/name
Position Position `json:"position"` // Position in grid
BoundBox BoundBox `json:"boundBox"` // Bounding box coordinates
}
// BoundBox represents bounding box coordinates
type BoundBox struct {
X float64 `json:"x"` // X coordinate
Y float64 `json:"y"` // Y coordinate
Width float64 `json:"width"` // Box width
Height float64 `json:"height"` // Box height
}
// Position represents grid position

View File

@@ -1,10 +1,11 @@
//go:build localtest
package llk
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -97,19 +98,6 @@ func convertToGameElementFromQueryResult(result *ai.QueryResult) (*GameElement,
return &gameElement, nil
}
// hasRequiredEnvVars checks if the required environment variables are set for testing
func hasRequiredEnvVars() bool {
// Check for OpenAI environment variables
if os.Getenv("OPENAI_BASE_URL") != "" && os.Getenv("OPENAI_API_KEY") != "" {
return true
}
// Check for GPT-4O specific environment variables
if os.Getenv("OPENAI_GPT_4O_BASE_URL") != "" && os.Getenv("OPENAI_GPT_4O_API_KEY") != "" {
return true
}
return false
}
// loadTestImage loads the test image from testdata
func loadTestImage(t *testing.T) (string, types.Size) {
screenshot, size, err := builtin.LoadImage("../../../uixt/ai/testdata/llk_1.png")
@@ -129,10 +117,6 @@ func createAIQueryer(t *testing.T) *ai.Querier {
// TestLLKGameBot_AnalyzeGameInterface comprehensive test for game interface analysis
func TestLLKGameBot_AnalyzeGameInterface(t *testing.T) {
if !hasRequiredEnvVars() {
t.Skip("Skipping test: required environment variables not set")
}
t.Run("AnalyzeWithTestImage", func(t *testing.T) {
// Create test bot and load test image
querier := createAIQueryer(t)

View File

@@ -1,3 +1,5 @@
//go:build localtest
package llk
import (
@@ -7,10 +9,11 @@ import (
"os"
"testing"
"github.com/httprunner/httprunner/v5/uixt/ai"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/httprunner/httprunner/v5/uixt/ai"
)
// TestLLKSolver tests the LianLianKan solver functionality

View File

@@ -1,61 +0,0 @@
package uitest
import (
"testing"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/uixt/option"
)
func TestAndroidDouyinE2E(t *testing.T) {
testCase := &hrp.TestCase{
Config: hrp.NewConfig("直播_抖音_端到端时延_android").
WithVariables(map[string]interface{}{
"device": "${ENV(SerialNumber)}",
"ups": "${ENV(LIVEUPLIST)}",
}).
SetAndroid(
option.WithSerialNumber("$device"),
option.WithAdbLogOn(true)),
TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音").
Android().
AppTerminate("com.ss.android.ugc.aweme").
AppLaunch("com.ss.android.ugc.aweme").
Home().
SwipeToTapApp(
"抖音",
option.WithMaxRetryTimes(5),
option.WithTapOffset(0, -50),
).
Sleep(20).
Validate().
AssertOCRExists("推荐", "进入抖音失败"),
hrp.NewStep("点击放大镜").
Android().
TapXY(0.9, 0.08).
Sleep(5),
hrp.NewStep("输入账号名称").
Android().
Input("$ups").
Sleep(5),
hrp.NewStep("点击搜索").
Android().
TapByOCR("搜索").
Sleep(5),
hrp.NewStep("端到端采集").Loop(5).
Android().
TapByOCR(
"直播中",
option.WithIgnoreNotFoundError(true),
option.WithIndex(-1),
).
EndToEndDelay(option.WithInterval(5), option.WithTimeout(120)).
TapByUITypes(option.WithScreenShotUITypes("close")),
},
}
if err := testCase.Dump2JSON("android_e2e_delay_test.json"); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,151 +0,0 @@
{
"config": {
"name": "直播_抖音_端到端时延_android",
"variables": {
"device": "${ENV(SerialNumber)}",
"ups": "${ENV(LIVEUPLIST)}"
},
"android": [
{
"serial": "$device",
"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": "app_terminate",
"params": "com.ss.android.ugc.aweme"
},
{
"method": "app_launch",
"params": "com.ss.android.ugc.aweme"
},
{
"method": "home"
},
{
"method": "swipe_to_tap_app",
"params": "抖音",
"options": {
"max_retry_times": 5,
"tap_offset": [
0,
-50
]
}
},
{
"method": "sleep",
"params": 20
}
]
},
"validate": [
{
"check": "ui_ocr",
"assert": "exists",
"expect": "推荐",
"msg": "进入抖音失败"
}
]
},
{
"name": "点击放大镜",
"android": {
"os_type": "android",
"actions": [
{
"method": "tap_xy",
"params": [
0.9,
0.08
],
"options": {}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "输入账号名称",
"android": {
"os_type": "android",
"actions": [
{
"method": "input",
"params": "$ups",
"options": {}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "点击搜索",
"android": {
"os_type": "android",
"actions": [
{
"method": "tap_ocr",
"params": "搜索",
"options": {}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "端到端采集",
"loops": 5,
"android": {
"os_type": "android",
"actions": [
{
"method": "tap_ocr",
"params": "直播中",
"options": {
"index": -1,
"ignore_NotFoundError": true
}
},
{
"method": "live_e2e",
"options": {
"interval": 5,
"timeout": 120
}
},
{
"method": "tap_cv",
"options": {
"screenshot_with_ui_types": [
"close"
]
}
}
]
}
}
]
}

View File

@@ -1,409 +0,0 @@
{
"config": {
"name": "安卓专家用例",
"variables": {
"app_name": "抖音",
"bundle_id": "com.ss.android.ugc.aweme",
"device": "${ENV(SerialNumber)}",
"query": "${ENV(query)}"
},
"android": [
{
"serial": "$device",
"log_on": true,
"adb_server_host": "localhost",
"adb_server_port": 5037,
"uia2": true,
"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": "app_launch 以及 ui_foreground_app equal 断言",
"android": {
"os_type": "android",
"actions": [
{
"method": "app_launch",
"params": "$bundle_id"
},
{
"method": "sleep",
"params": 2
}
]
},
"validate": [
{
"check": "ui_foreground_app",
"assert": "equal",
"expect": "$bundle_id",
"msg": "app [$bundle_id] should be in foreground"
}
]
},
{
"name": "home 以及 swipe_to_tap_app 默认配置",
"android": {
"os_type": "android",
"actions": [
{
"method": "home"
},
{
"method": "swipe_to_tap_app",
"params": "$app_name",
"options": {}
},
{
"method": "sleep",
"params": 10
}
]
}
},
{
"name": "处理弹窗 close_popups 默认配置 以及 ui_ocr exists 断言",
"android": {
"os_type": "android",
"actions": [
{
"method": "close_popups",
"options": {}
}
]
},
"validate": [
{
"check": "ui_ocr",
"assert": "exists",
"expect": "推荐",
"msg": "进入抖音失败"
}
]
},
{
"name": "【直播】feed头像或卡片进房 swipe_to_tap_texts 自定义配置",
"android": {
"os_type": "android",
"actions": [
{
"method": "swipe_to_tap_texts",
"params": [
"直播",
"直播中",
"点击进入直播间"
],
"options": {
"identifier": "click_live",
"max_retry_times": 50,
"interval": 1.5,
"direction": [
0.5,
0.7,
0.5,
0.3
],
"scope": [
0.2,
0.2,
1,
0.8
]
}
}
]
}
},
{
"name": "sleep 10s",
"android": {
"os_type": "android",
"actions": [
{
"method": "sleep",
"params": 10
}
]
}
},
{
"name": "【直播】swipe 自定义配置 以及 back",
"android": {
"os_type": "android",
"actions": [
{
"method": "swipe_coordinate",
"params": [
0.5,
0.7,
0.5,
0.3
],
"options": {
"identifier": "slide_in_live"
}
},
{
"method": "sleep",
"params": 5
},
{
"method": "back"
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "【搜索】点击放大镜 tap_xy 自定义配置",
"android": {
"os_type": "android",
"actions": [
{
"method": "tap_xy",
"params": [
0.9,
0.08
],
"options": {
"identifier": "click_search_in_middle_page"
}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "【搜索】输入query词 input",
"android": {
"os_type": "android",
"actions": [
{
"method": "input",
"params": "$query",
"options": {
"identifier": "input_query"
}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "【搜索】点击搜索按钮 tap_ocr 自定义配置",
"android": {
"os_type": "android",
"actions": [
{
"method": "tap_ocr",
"params": "搜索",
"options": {
"identifier": "click_search_after_input_query"
}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "选择直播页签 tap_ocr 默认配置",
"android": {
"os_type": "android",
"actions": [
{
"method": "tap_ocr",
"params": "直播",
"options": {}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "【生活服务】进入直播间 tap_xy",
"android": {
"os_type": "android",
"actions": [
{
"method": "tap_xy",
"params": [
0.5,
0.5
],
"options": {}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "【生活服务】点击货架商品 tap_ocr 自定义配置",
"android": {
"os_type": "android",
"actions": [
{
"method": "tap_cv",
"options": {
"identifier": "click_sales_rack",
"screenshot_with_ui_types": [
"dyhouse",
"shoppingbag"
]
}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "app_terminate 以及 ui_foreground_app not_equal 断言",
"android": {
"os_type": "android",
"actions": [
{
"method": "app_terminate",
"params": "$bundle_id"
},
{
"method": "sleep",
"params": 2
}
]
},
"validate": [
{
"check": "ui_foreground_app",
"assert": "not_equal",
"expect": "$bundle_id",
"msg": "app [$bundle_id] should not be in foreground"
}
]
},
{
"name": "home 以及 swipe_to_tap_app 自定义配置",
"android": {
"os_type": "android",
"actions": [
{
"method": "home"
},
{
"method": "swipe_to_tap_app",
"params": "$app_name",
"options": {
"max_retry_times": 5,
"interval": 1,
"tap_offset": [
0,
-50
]
}
},
{
"method": "sleep",
"params": 10
}
]
}
},
{
"name": "处理弹窗 close_popups 自定义配置 以及 ui_ocr exists 断言",
"android": {
"os_type": "android",
"actions": [
{
"method": "close_popups",
"options": {
"max_retry_times": 3,
"interval": 2
}
}
]
},
"validate": [
{
"check": "ui_ocr",
"assert": "exists",
"expect": "推荐",
"msg": "进入抖音失败"
}
]
},
{
"name": "返回主界面,并打开本地时间戳",
"android": {
"os_type": "android",
"actions": [
{
"method": "home"
},
{
"method": "app_terminate",
"params": "$bundle_id"
},
{
"method": "sleep",
"params": 3
},
{
"method": "swipe_to_tap_app",
"params": "local",
"options": {
"max_retry_times": 5
}
},
{
"method": "sleep",
"params": 10
}
]
}
},
{
"name": "screeshot 以及 sleep_random",
"loops": 3,
"android": {
"os_type": "android",
"actions": [
{
"method": "screenshot",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
3
]
}
]
}
}
]
}

View File

@@ -0,0 +1,171 @@
{
"config": {
"name": "起点_安卓_无限流加载耗时",
"variables": {
"device": "${ENV(SerialNumber)}"
},
"android": [
{
"serial": "$device",
"log_on": true,
"ignore_popup": true
}
]
},
"teststeps": [
{
"name": "杀掉之前清除缓存的进程",
"android": {
"actions": [
{
"method": "app_terminate",
"params": "com.qidian.QDReader"
},
{
"method": "sleep",
"params": 30
}
]
}
},
{
"name": "冷启动起点读书app",
"android": {
"actions": [
{
"method": "app_launch",
"params": "com.qidian.QDReader"
},
{
"method": "sleep",
"params": 30
}
]
}
},
{
"name": "进入精选-男生频道",
"android":{
"actions":[
{
"method": "tap_ocr",
"params": "精选",
"offset": [
0,
-50
]
},
{
"method": "sleep",
"params": 7
},
{
"method": "tap_ocr",
"params": "男生"
},
{
"method": "sleep",
"params": 7
}
]
}
},
{
"name": "向下滑动,触发加载",
"android": {
"actions": [
{
"method": "swipe",
"params": [
0.5,
0.8,
0.5,
0.2
],
"steps": 1,
"identifier": "xiaoshuo_swip_tab_loadmore"
},
{
"method": "sleep",
"params": 3
},
{
"method": "swipe",
"params": [
0.5,
0.8,
0.5,
0.2
],
"steps": 1
},
{
"method": "sleep",
"params": 3
},
{
"method": "swipe",
"params": [
0.5,
0.8,
0.5,
0.2
],
"steps": 1
},
{
"method": "sleep",
"params": 3
},
{
"method": "swipe",
"params": [
0.5,
0.8,
0.5,
0.2
],
"steps": 1
},
{
"method": "sleep",
"params": 3
},
{
"method": "swipe",
"params": [
0.5,
0.8,
0.5,
0.2
],
"steps": 1
}
]
}
},
{
"name": "返回",
"android": {
"actions": [
{
"method": "home"
},
{
"method": "swipe_to_tap_app",
"params": "local",
"offset": [
0,
-50
]
},
{
"method": "sleep",
"params": 7
}
]
}
}
]
}

View File

@@ -1,66 +0,0 @@
package uitest
import (
"testing"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/uixt/option"
)
func TestHarmonyDouyinE2E(t *testing.T) {
testCase := &hrp.TestCase{
Config: hrp.NewConfig("直播_抖音_端到端时延_harmony").
WithVariables(map[string]interface{}{
"device": "${ENV(SerialNumber)}",
"ups": "${ENV(LIVEUPLIST)}",
}).
SetHarmony(
option.WithConnectKey("$device"),
option.WithLogOn(true)),
TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音").
Harmony().
AppTerminate("com.ss.hm.ugc.aweme").
SwipeToTapApp("com.ss.hm.ugc.aweme").
Home().
SwipeToTapApp(
"抖音",
option.WithMaxRetryTimes(5),
option.WithTapOffset(0, -50),
).
Sleep(20).
Validate().
AssertOCRExists("推荐", "进入抖音失败"),
hrp.NewStep("点击放大镜").
Harmony().
TapXY(0.9, 0.08).
Sleep(5),
hrp.NewStep("输入账号名称").
Harmony().
Input("$ups").
Sleep(5),
hrp.NewStep("点击搜索").
Harmony().
TapByOCR("搜索").
Sleep(5),
hrp.NewStep("端到端采集").Loop(5).
Harmony().
TapByOCR(
"直播中",
option.WithIgnoreNotFoundError(true),
option.WithIndex(-1),
).
EndToEndDelay(option.WithInterval(5), option.WithTimeout(120)).
TapByUITypes(option.WithScreenShotUITypes("close")),
},
}
if err := testCase.Dump2JSON("harmony_e2e_delay_test.json"); err != nil {
t.Fatal(err)
}
err := hrp.Run(t, testCase)
if err != nil {
t.Fatal(err)
}
}

View File

@@ -1,146 +0,0 @@
{
"config": {
"name": "直播_抖音_端到端时延_harmony",
"variables": {
"device": "${ENV(SerialNumber)}",
"ups": "${ENV(LIVEUPLIST)}"
},
"harmony": [
{
"connect_key": "$device",
"log_on": true
}
]
},
"teststeps": [
{
"name": "启动抖音",
"harmony": {
"os_type": "harmony",
"actions": [
{
"method": "app_terminate",
"params": "com.ss.hm.ugc.aweme"
},
{
"method": "swipe_to_tap_app",
"params": "com.ss.hm.ugc.aweme",
"options": {}
},
{
"method": "home"
},
{
"method": "swipe_to_tap_app",
"params": "抖音",
"options": {
"max_retry_times": 5,
"tap_offset": [
0,
-50
]
}
},
{
"method": "sleep",
"params": 20
}
]
},
"validate": [
{
"check": "ui_ocr",
"assert": "exists",
"expect": "推荐",
"msg": "进入抖音失败"
}
]
},
{
"name": "点击放大镜",
"harmony": {
"os_type": "harmony",
"actions": [
{
"method": "tap_xy",
"params": [
0.9,
0.08
],
"options": {}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "输入账号名称",
"harmony": {
"os_type": "harmony",
"actions": [
{
"method": "input",
"params": "$ups",
"options": {}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "点击搜索",
"harmony": {
"os_type": "harmony",
"actions": [
{
"method": "tap_ocr",
"params": "搜索",
"options": {}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "端到端采集",
"loops": 5,
"harmony": {
"os_type": "harmony",
"actions": [
{
"method": "tap_ocr",
"params": "直播中",
"options": {
"index": -1,
"ignore_NotFoundError": true
}
},
{
"method": "live_e2e",
"options": {
"interval": 5,
"timeout": 120
}
},
{
"method": "tap_cv",
"options": {
"screenshot_with_ui_types": [
"close"
]
}
}
]
}
}
]
}

View File

@@ -1,388 +0,0 @@
{
"config": {
"name": "iOS 专家用例",
"variables": {
"app_name": "抖音",
"bundle_id": "com.ss.iphone.ugc.Aweme",
"device": "${ENV(UDID)}",
"query": "${ENV(query)}"
},
"ios": [
{
"udid": "$device",
"port": 8700,
"mjpeg_port": 8800,
"log_on": true
}
]
},
"teststeps": [
{
"name": "启动应用程序 app_launch",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "app_launch",
"params": "$bundle_id"
},
{
"method": "sleep",
"params": 2
}
]
}
},
{
"name": "home 以及 swipe_to_tap_app 默认配置",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "home"
},
{
"method": "swipe_to_tap_app",
"params": "$app_name",
"options": {}
},
{
"method": "sleep",
"params": 10
}
]
}
},
{
"name": "处理弹窗 close_popups 默认配置 以及 ui_ocr exists 断言",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "close_popups",
"options": {}
}
]
},
"validate": [
{
"check": "ui_ocr",
"assert": "exists",
"expect": "推荐",
"msg": "进入抖音失败"
}
]
},
{
"name": "【直播】feed头像或卡片进房 swipe_to_tap_texts 自定义配置",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "swipe_to_tap_texts",
"params": [
"直播",
"直播中",
"点击进入直播间"
],
"options": {
"identifier": "click_live",
"max_retry_times": 50,
"interval": 1.5,
"direction": [
0.5,
0.7,
0.5,
0.3
],
"scope": [
0.2,
0.2,
1,
0.8
]
}
}
]
}
},
{
"name": "sleep 10s",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "sleep",
"params": 10
}
]
}
},
{
"name": "【直播】swipe 自定义配置 以及 back",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "swipe_coordinate",
"params": [
0.5,
0.7,
0.5,
0.3
],
"options": {
"identifier": "slide_in_live"
}
},
{
"method": "sleep",
"params": 5
},
{
"method": "back"
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "【搜索】点击放大镜 tap_xy 自定义配置",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "tap_xy",
"params": [
0.9,
0.075
],
"options": {
"identifier": "click_search_in_middle_page"
}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "【搜索】输入query词 input",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "input",
"params": "$query",
"options": {
"identifier": "input_query"
}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "【搜索】点击搜索按钮 tap_ocr 自定义配置",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "tap_ocr",
"params": "搜索",
"options": {
"identifier": "click_search_after_input_query"
}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "选择直播页签 tap_ocr 默认配置",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "tap_ocr",
"params": "直播",
"options": {}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "【生活服务】进入直播间 tap_xy",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "tap_xy",
"params": [
0.5,
0.5
],
"options": {}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "【生活服务】点击货架商品 tap_ocr 自定义配置",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "tap_cv",
"options": {
"identifier": "click_sales_rack",
"screenshot_with_ui_types": [
"dyhouse",
"shoppingbag"
]
}
},
{
"method": "sleep",
"params": 5
}
]
}
},
{
"name": "终止应用程序 app_terminate",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "app_terminate",
"params": "$bundle_id"
},
{
"method": "sleep",
"params": 2
}
]
}
},
{
"name": "home 以及 swipe_to_tap_app 自定义配置",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "home"
},
{
"method": "swipe_to_tap_app",
"params": "$app_name",
"options": {
"max_retry_times": 5,
"interval": 1,
"tap_offset": [
0,
-50
]
}
},
{
"method": "sleep",
"params": 10
}
]
}
},
{
"name": "处理弹窗 close_popups 自定义配置 以及 ui_ocr exists 断言",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "close_popups",
"options": {
"max_retry_times": 3,
"interval": 2
}
}
]
},
"validate": [
{
"check": "ui_ocr",
"assert": "exists",
"expect": "推荐",
"msg": "进入抖音失败"
}
]
},
{
"name": "返回主界面,并打开本地时间戳",
"ios": {
"os_type": "ios",
"actions": [
{
"method": "home"
},
{
"method": "app_terminate",
"params": "$bundle_id"
},
{
"method": "sleep",
"params": 3
},
{
"method": "swipe_to_tap_app",
"params": "local",
"options": {
"max_retry_times": 5
}
},
{
"method": "sleep",
"params": 10
}
]
}
},
{
"name": "screeshot 以及 sleep_random",
"loops": 3,
"ios": {
"os_type": "ios",
"actions": [
{
"method": "screenshot",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
3
]
}
]
}
}
]
}

View File

@@ -0,0 +1,204 @@
//go:build localtest
package uitest
import (
"os"
"testing"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/uixt"
"github.com/httprunner/httprunner/v5/uixt/option"
)
// TestIOSStepMultipleSIMActions tests multiple SIM actions in a step-like manner for iOS
func TestIOSStepMultipleSIMActions(t *testing.T) {
// 创建包含多个 iOS SIM 操作的测试用例
testCase := &hrp.TestCase{
Config: hrp.NewConfig("iOS多个SIM操作组合测试").SetIOS(option.WithUDID("")),
TestSteps: []hrp.IStep{
hrp.NewStep("iOS组合SIM操作测试").
IOS().
SIMClickAtPoint(0.5, 0.5). // 点击屏幕中心
Sleep(1). // 等待1秒
SIMSwipeWithDirection("up", 0.5, 0.7, 200.0, 400.0). // 向上滑动
Sleep(0.5). // 等待0.5秒
SIMSwipeInArea("up", 0.2, 0.2, 0.6, 0.6, 350.0, 500.0). // 在区域内向上滑动
Sleep(0.5). // 等待0.5秒
SIMSwipeFromPointToPoint(0.1, 0.5, 0.9, 0.5). // 从左到右滑动
Sleep(0.5). // 等待0.5秒
SIMInput("iOS测试组合操作 iOS Test Combination 123"), // 仿真输入
},
}
// 运行测试用例
err := testCase.Dump2JSON("TestIOSStepMultipleSIMActions.json")
if err != nil {
t.Fatalf("Failed to dump test case: %v", err)
}
defer func() {
// 清理生成的文件
_ = os.Remove("TestIOSStepMultipleSIMActions.json")
}()
// 执行测试用例
err = hrp.NewRunner(t).Run(testCase)
if err != nil {
t.Logf("Expected error (no iOS device): %v", err)
// 这是预期的错误,因为没有连接 iOS 设备
if !containsString(err.Error(), "no attached ios devices") &&
!containsString(err.Error(), "device general connection error") {
t.Errorf("Unexpected error: %v", err)
}
}
t.Logf("Successfully executed multiple iOS SIM actions test (step level)")
}
// TestIOSDriverDirectSIMFunctions tests iOS SIM functions directly via driver
func TestIOSDriverDirectSIMFunctions(t *testing.T) {
device, err := uixt.NewIOSDevice(
option.WithUDID(""),
)
if err != nil {
t.Logf("Expected error (no iOS device): %v", err)
// 这是预期的错误,因为没有连接 iOS 设备
if !containsString(err.Error(), "no attached ios devices") &&
!containsString(err.Error(), "device general connection error") {
t.Errorf("Unexpected error: %v", err)
}
return
}
driver, err := uixt.NewWDADriver(device)
if err != nil {
t.Logf("Expected error (cannot create driver): %v", err)
return
}
defer driver.TearDown()
// 验证 WDADriver 实现了 SIMSupport 接口
var iDriver uixt.IDriver = driver
simSupport, ok := iDriver.(uixt.SIMSupport)
if !ok {
t.Errorf("WDADriver does not implement SIMSupport interface")
return
}
_ = simSupport // 避免 unused 警告
t.Run("SIMClickAtPoint", func(t *testing.T) {
err := driver.SIMClickAtPoint(0.5, 0.5)
if err != nil {
t.Logf("SIMClickAtPoint error (expected if no device): %v", err)
} else {
t.Logf("Successfully executed SIMClickAtPoint at (0.5, 0.5)")
}
})
t.Run("SIMSwipeWithDirection", func(t *testing.T) {
err := driver.SIMSwipeWithDirection("up", 0.5, 0.7, 200.0, 400.0)
if err != nil {
t.Logf("SIMSwipeWithDirection error (expected if no device): %v", err)
} else {
t.Logf("Successfully executed SIMSwipeWithDirection")
}
})
t.Run("SIMSwipeInArea", func(t *testing.T) {
err := driver.SIMSwipeInArea("up", 0.2, 0.2, 0.6, 0.6, 350.0, 500.0)
if err != nil {
t.Logf("SIMSwipeInArea error (expected if no device): %v", err)
} else {
t.Logf("Successfully executed SIMSwipeInArea")
}
})
t.Run("SIMSwipeFromPointToPoint", func(t *testing.T) {
err := driver.SIMSwipeFromPointToPoint(0.1, 0.5, 0.9, 0.5)
if err != nil {
t.Logf("SIMSwipeFromPointToPoint error (expected if no device): %v", err)
} else {
t.Logf("Successfully executed SIMSwipeFromPointToPoint")
}
})
t.Run("SIMInput", func(t *testing.T) {
err := driver.SIMInput("iOS测试文本 Test iOS Input 123")
if err != nil {
t.Logf("SIMInput error (expected if no device): %v", err)
} else {
t.Logf("Successfully executed SIMInput")
}
})
}
// TestIOSMCPToolsIntegration tests iOS SIM functions via MCP tools (integration test)
func TestIOSMCPToolsIntegration(t *testing.T) {
// 这个测试验证 MCP 工具层是否正确支持 iOS SIM 功能
device, err := uixt.NewIOSDevice(
option.WithUDID(""),
)
if err != nil {
t.Logf("Expected error (no iOS device): %v", err)
// 验证错误类型
if !containsString(err.Error(), "no attached ios devices") &&
!containsString(err.Error(), "device general connection error") {
t.Errorf("Unexpected error: %v", err)
}
return
}
// 需要先创建 WDADriver然后创建 XTDriver
wdaDriver, err := uixt.NewWDADriver(device)
if err != nil {
t.Logf("Cannot create WDADriver: %v", err)
return
}
defer wdaDriver.TearDown()
xtDriver, err := uixt.NewXTDriver(wdaDriver)
if err != nil {
t.Logf("Cannot create XTDriver: %v", err)
return
}
// 验证 XTDriver 的底层驱动实现了 SIMSupport 接口
if _, ok := xtDriver.IDriver.(uixt.SIMSupport); !ok {
t.Errorf("XTDriver's underlying driver does not implement SIMSupport interface")
return
}
t.Logf("XTDriver's underlying driver correctly implements SIMSupport interface")
// 简化测试 - 仅验证接口实现,因为 MCP 服务器的内部结构复杂
simTools := []option.ActionName{
option.ACTION_SIMClickAtPoint,
option.ACTION_SIMSwipeDirection,
option.ACTION_SIMSwipeInArea,
option.ACTION_SIMSwipeFromPointToPoint,
option.ACTION_SIMInput,
}
// 验证这些工具确实存在于系统中
t.Logf("Verified SIM tools: %v", simTools)
t.Logf("iOS MCP tools integration test completed - all tools are registered")
}
// Helper function to check if a string contains a substring
func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr ||
(len(s) > len(substr) &&
(s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
findSubstring(s, substr))))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -0,0 +1,206 @@
{
"config": {
"name": "视频号搜索",
"ai_options": {
"llm_service": "doubao-1.5-ui-tars-250328"
}
},
"teststeps": [
{
"name": "启动视频号 app",
"android": {
"os_type": "android",
"actions": [
{
"method": "app_launch",
"params": "com.tencent.mm"
},
{
"method": "sleep",
"params": 5
}
]
},
"validate": [
{
"check": "ui_foreground_app",
"assert": "equal",
"expect": "com.tencent.mm",
"msg": "app [com.tencent.mm] should be in foreground"
}
]
},
{
"name": "进入视频号搜索页面",
"android": {
"os_type": "android",
"actions": [
{
"method": "start_to_goal",
"params": "进入「发现」页,点击进入「视频号」页面,点击搜索框",
"options": {}
}
]
},
"validate": [
{
"check": "ui_ai",
"assert": "ai_assert",
"expect": "当前页面包含搜索框和搜索按钮",
"msg": "assert ai prompt [当前页面包含搜索框和搜索按钮] failed"
}
]
},
{
"name": "搜索短剧「$dramaName」",
"parameters": {
"dramaName": [
"督军,你家小福包有祖传乌鸦嘴",
"换亲后我顺便换了江山,很合理吧",
"穿过荆棘拥抱你",
"认亲后,误入帮派成团宠",
"欲念疯长",
"花轿临门她拒嫁,只盼故人归",
"太监武帝,功法自动大圆满",
"容先生,你的爱意藏不住了",
"回家给娘亲改命,心声咋还泄露了",
"相亲遇甜妹,偷娶她闺蜜"
]
},
"android": {
"os_type": "android",
"actions": [
{
"method": "start_to_goal",
"params": "输入「$dramaName」点击搜索",
"options": {}
},
{
"method": "sleep",
"params": 1
},
{
"method": "swipe_direction",
"params": "up",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
2
]
},
{
"method": "swipe_direction",
"params": "up",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
2
]
},
{
"method": "swipe_direction",
"params": "up",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
2
]
},
{
"method": "swipe_direction",
"params": "up",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
2
]
},
{
"method": "swipe_direction",
"params": "up",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
2
]
},
{
"method": "swipe_direction",
"params": "up",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
2
]
},
{
"method": "swipe_direction",
"params": "up",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
2
]
},
{
"method": "swipe_direction",
"params": "up",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
2
]
},
{
"method": "swipe_direction",
"params": "up",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
2
]
},
{
"method": "swipe_direction",
"params": "up",
"options": {}
},
{
"method": "sleep_random",
"params": [
1,
2
]
}
]
}
}
]
}

View File

@@ -0,0 +1,64 @@
package uitest
import (
"testing"
"github.com/stretchr/testify/require"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/uixt/option"
)
func TestSPHSearchPage(t *testing.T) {
testCase := &hrp.TestCase{
Config: hrp.NewConfig("视频号搜索").
SetLLMService(option.DOUBAO_1_5_UI_TARS_250328), // Configure LLM service for AI operations
TestSteps: []hrp.IStep{
hrp.NewStep("启动视频号 app").
Android().
AppLaunch("com.tencent.mm").
Sleep(5).
Validate().
AssertAppInForeground("com.tencent.mm"),
hrp.NewStep("进入视频号搜索页面").
Android().
StartToGoal("进入「发现」页,点击进入「视频号」页面,点击搜索框").
Validate().
AssertAI("当前页面包含搜索框和搜索按钮"),
hrp.NewStep("搜索短剧「$dramaName」").
WithParameters(map[string]interface{}{
"dramaName": []string{
"督军,你家小福包有祖传乌鸦嘴",
"换亲后我顺便换了江山,很合理吧",
"穿过荆棘拥抱你",
"认亲后,误入帮派成团宠",
"欲念疯长",
"花轿临门她拒嫁,只盼故人归",
"太监武帝,功法自动大圆满",
"容先生,你的爱意藏不住了",
"回家给娘亲改命,心声咋还泄露了",
"相亲遇甜妹,偷娶她闺蜜",
},
}).
Android().
StartToGoal("输入「$dramaName」点击搜索").
Sleep(1).
SwipeUp().SleepRandom(1, 2).
SwipeUp().SleepRandom(1, 2).
SwipeUp().SleepRandom(1, 2).
SwipeUp().SleepRandom(1, 2).
SwipeUp().SleepRandom(1, 2).
SwipeUp().SleepRandom(1, 2).
SwipeUp().SleepRandom(1, 2).
SwipeUp().SleepRandom(1, 2).
SwipeUp().SleepRandom(1, 2).
SwipeUp().SleepRandom(1, 2),
},
}
err := testCase.Dump2JSON("sph_search.json")
require.Nil(t, err)
// err := hrp.NewRunner(t).Run(testCase)
// assert.Nil(t, err)
}

View File

@@ -25,7 +25,7 @@ const (
CaseFileName = "case.json" // $PWD/results/20060102150405/case.json
// mobile device path
DeviceActionLogFilePath = "/sdcard/Android/data/io.appium.uiautomator2.server/files/hodor"
DeviceActionLogFilePath = "/storage/emulated/0/Download/"
)
type Config struct {

View File

@@ -139,6 +139,16 @@ func getDeviceConfig(deviceModel string) DeviceConfig {
SizeMax: 225.0,
}
// "Google"
case "iphone":
return DeviceConfig{
DeviceID: 2,
PressureMin: 1,
PressureMax: 1,
SizeMin: 0.03,
SizeMax: 0.04,
}
// Default configuration for unknown devices
default:
return DeviceConfig{

View File

@@ -610,27 +610,68 @@ func (d *Device) Pull(remotePath string, dest io.Writer) (err error) {
return
}
func (d *Device) PullFolder(remotePath string, localPath string) (err error) {
// Check if remote path exists and is a directory
fileInfos, err := d.List(remotePath)
if err != nil {
return fmt.Errorf("failed to list remote directory: %w", err)
}
// Create local directory if it doesn't exist
if err = os.MkdirAll(localPath, 0o755); err != nil {
return fmt.Errorf("failed to create local directory: %w", err)
}
// Pull each file/directory recursively
for _, fileInfo := range fileInfos {
remoteItemPath := remotePath + "/" + fileInfo.Name
localItemPath := localPath + "/" + fileInfo.Name
if fileInfo.IsDir() {
// Recursively pull subdirectory
if err = d.PullFolder(remoteItemPath, localItemPath); err != nil {
return fmt.Errorf("failed to pull subdirectory %s: %w", remoteItemPath, err)
}
} else {
// Pull file
if err = d.PullFile(remoteItemPath, localItemPath); err != nil {
return fmt.Errorf("failed to pull file %s: %w", remoteItemPath, err)
}
}
}
return nil
}
func (d *Device) PullFile(remotePath string, localPath string) (err error) {
// Create local file
localFile, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
defer localFile.Close()
// Use existing Pull method to pull file content
if err = d.Pull(remotePath, localFile); err != nil {
return fmt.Errorf("failed to pull file content: %w", err)
}
return nil
}
func (d *Device) installViaABBExec(apk io.ReadSeeker, args ...string) (raw []byte, err error) {
var (
tp transport
filesize int64
)
timeout := 8
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Minute)
defer cancel()
filesize, err = apk.Seek(0, io.SeekEnd)
if err != nil {
return nil, err
}
if tp, err = d.createDeviceTransport(4 * time.Minute); err != nil {
if tp, err = d.createDeviceTransport(5 * time.Minute); err != nil {
return nil, err
}
defer func() { _ = tp.Close() }()
go func() {
<-ctx.Done()
_ = tp.Close()
}()
cmd := "abb_exec:package\x00install\x00-t"
for _, arg := range args {
cmd += "\x00" + arg
@@ -649,9 +690,6 @@ func (d *Device) installViaABBExec(apk io.ReadSeeker, args ...string) (raw []byt
return nil, err
}
raw, err = tp.ReadBytesAll()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, fmt.Errorf("installation timed out after %d minutes", timeout)
}
return
}

View File

@@ -296,6 +296,17 @@ func TestDevice_Pull(t *testing.T) {
}
}
func TestDevice_PullFolder(t *testing.T) {
setupDevices(t)
for _, dev := range devices {
err := dev.PullFolder("/storage/emulated/0/Download/", "/tmp/test/")
if err != nil {
t.Fatal(err)
}
}
}
func TestDevice_ScreenRecord(t *testing.T) {
setupDevices(t)

View File

@@ -1,8 +1,9 @@
//go:build localtest
package ai
import (
"context"
"os"
"testing"
"github.com/httprunner/httprunner/v5/internal/builtin"
@@ -11,24 +12,7 @@ import (
"github.com/stretchr/testify/require"
)
// hasRequiredEnvVars checks if the required environment variables are set for testing
func hasRequiredEnvVars() bool {
// Check for OpenAI environment variables
if os.Getenv("OPENAI_BASE_URL") != "" && os.Getenv("OPENAI_API_KEY") != "" {
return true
}
// Check for GPT-4O specific environment variables
if os.Getenv("OPENAI_GPT_4O_BASE_URL") != "" && os.Getenv("OPENAI_GPT_4O_API_KEY") != "" {
return true
}
return false
}
func TestILLMServiceQuery(t *testing.T) {
// Skip test if required environment variables are not set
if !hasRequiredEnvVars() {
t.Skip("Skipping test: required environment variables not set")
}
// Create LLM service
service, err := NewLLMService(option.OPENAI_GPT_4O)
@@ -96,10 +80,6 @@ func TestILLMServiceQuery(t *testing.T) {
}
func TestILLMServiceIntegration(t *testing.T) {
// Skip test if required environment variables are not set
if !hasRequiredEnvVars() {
t.Skip("Skipping test: required environment variables not set")
}
// Create LLM service
service, err := NewLLMService(option.OPENAI_GPT_4O)

View File

@@ -26,7 +26,6 @@ type WingsService struct {
bizId string
accessKey string
secretKey string
history []History // Conversation history for Wings API
}
// NewWingsService creates a new Wings service instance
@@ -50,7 +49,6 @@ func NewWingsService() (ILLMService, error) {
bizId: bizID,
accessKey: accessKey,
secretKey: secretKey,
history: []History{},
}, nil
}
@@ -61,11 +59,6 @@ func (w *WingsService) Plan(ctx context.Context, opts *PlanningOptions) (*Planni
return nil, errors.Wrap(err, "validate planning parameters failed")
}
// Reset history if requested
if opts.ResetHistory {
w.resetHistory()
}
// Extract screenshot from message
screenshot, err := w.extractScreenshotFromMessage(opts.Message)
if err != nil {
@@ -77,11 +70,15 @@ func (w *WingsService) Plan(ctx context.Context, opts *PlanningOptions) (*Planni
// Prepare Wings API request
apiRequest := WingsActionRequest{
Historys: w.history,
DeviceInfo: deviceInfo,
StepText: fmt.Sprintf("%s", opts.UserInstruction),
BizId: w.bizId,
TextCase: fmt.Sprintf("整体描述:\n前置条件\n操作步骤\n%s\n停止操作。\n注意事项\n", opts.UserInstruction),
Historys: []interface{}{}, // empty as specified
DeviceInfos: []WingsDeviceInfo{
deviceInfo,
},
StepText: opts.UserInstruction,
BizId: w.bizId,
TextCase: "整体描述:\\n前置条件\\n获取 1 台设备 A。\\n获取 1 个[万粉创作者]账号a。\\n获取 2 个[普通]账号 b、c。\\n账号 a 和账号 b 互相关注。\\n账号 a 和账号 c 互相关注。\\n账号 a 给账号 b 设置备注为 “11131b”。\\n账号 a 给账号 c 设置备注为 “11131c”。\\n账号 a 创建一个粉丝群 m。\\n 账号 a 修改粉丝群 m 名称为“11131群”。\\n 账号 a 邀请账号 b 加入粉丝群 m。\\n账号 a 邀请账号 c 加入粉丝群 m。\\n账号 a 给群聊 m 发送一条文字消息。\\n设备 A 打开抖音 app。\\n设备 A 登录账号 a。\\n设备 A 退出抖音 app。\\n操作步骤\\n账号a打开抖音app。\\n点击“消息”。\\n点击“11131群”cell。\\n点击“聊天信息页入口”按钮。\\n点击“分享公开群”按钮。\\n点击文字“群口令”。\\n断言屏幕中存在文字“口令复制成功”。\\n停止操作。\\n注意事项\\n",
StepType: "automation",
DeviceID: deviceInfo.DeviceID,
Base: WingsBase{
LogID: generateWingsUUID(),
},
@@ -101,7 +98,7 @@ func (w *WingsService) Plan(ctx context.Context, opts *PlanningOptions) (*Planni
}
// Check API response status
if response.BaseResp.StatusCode != 0 && response.BaseResp.StatusCode != 200 {
if response.BaseResp.StatusCode != 0 {
err = fmt.Errorf("API returned error: %s", response.BaseResp.StatusMessage)
return &PlanningResult{
Thought: response.ThoughtChain.Thought,
@@ -110,50 +107,26 @@ func (w *WingsService) Plan(ctx context.Context, opts *PlanningOptions) (*Planni
}, err
}
// Update history with response data
newHistoryEntry := History{
Observation: response.ThoughtChain.Observation,
Thought: response.ThoughtChain.Thought,
Summary: response.ThoughtChain.Summary,
StepText: response.StepText,
StepTextTrans: response.StepTextTrans,
OriStepIndex: response.OriStepIndex,
DeviceID: deviceInfo[0].DeviceID,
AgentType: response.AgentType,
ActionResult: "", // Always empty as requested
DeviceInfos: &deviceInfo,
ActionParams: response.ActionParams,
// Convert Wings API response to tool calls
toolCalls, err := w.convertWingsResponseToToolCalls(response.ActionParams)
if err != nil {
return &PlanningResult{
Thought: response.ThoughtChain.Thought,
Error: err.Error(),
ModelName: "wings-api",
}, errors.Wrap(err, "convert Wings response to tool calls failed")
}
w.history = append(w.history, newHistoryEntry)
var toolCalls []schema.ToolCall
if response.StepType != "FINISH" {
// Convert Wings API response to tool calls
toolCalls, err = w.convertWingsResponseToToolCalls(response.ActionParams)
if err != nil {
return &PlanningResult{
Thought: response.ThoughtChain.Thought,
Error: err.Error(),
ModelName: "wings-api",
}, errors.Wrap(err, "convert Wings response to tool calls failed")
}
}
// No need to update ActionResult as per user request
// ActionResult should always be empty
log.Info().
Str("thought", response.ThoughtChain.Thought).
Str("action", response.AgentType).
Str("action_params", response.ActionParams).
Str("log_id", fmt.Sprintf("%v", response.BaseResp.Extra)).
Int("tool_calls_count", len(toolCalls)).
Int64("elapsed_ms", elapsed).
Msg("Wings API planning completed")
return &PlanningResult{
ToolCalls: toolCalls,
Thought: response.StepTextTrans,
Content: response.StepTextTrans,
Thought: response.ThoughtChain.Thought,
Content: response.ThoughtChain.Summary,
ModelName: "wings-api",
}, nil
}
@@ -173,15 +146,20 @@ func (w *WingsService) Assert(ctx context.Context, opts *AssertOptions) (*Assert
// Prepare Wings API request for assertion
apiRequest := WingsActionRequest{
Historys: []History{},
DeviceInfo: deviceInfo,
StepText: fmt.Sprintf("断言:%s", opts.Assertion),
BizId: w.bizId,
TextCase: fmt.Sprintf("整体描述:\n前置条件\n操作步骤\n断言: %s\n停止操作。\n注意事项\n", opts.Assertion),
Historys: []interface{}{}, // empty as specified
DeviceInfos: []WingsDeviceInfo{
deviceInfo,
},
StepText: opts.Assertion,
BizId: w.bizId,
TextCase: "整体描述:\\n前置条件\\n获取 1 台设备 A。\\n获取 1 个[万粉创作者]账号a。\\n获取 2 个[普通]账号 b、c。\\n账号 a 和账号 b 互相关注。\\n账号 a 和账号 c 互相关注。\\n账号 a 给账号 b 设置备注为 “11131b”。\\n账号 a 给账号 c 设置备注为 “11131c”。\\n账号 a 创建一个粉丝群 m。\\n 账号 a 修改粉丝群 m 名称为“11131群”。\\n 账号 a 邀请账号 b 加入粉丝群 m。\\n账号 a 邀请账号 c 加入粉丝群 m。\\n账号 a 给群聊 m 发送一条文字消息。\\n设备 A 打开抖音 app。\\n设备 A 登录账号 a。\\n设备 A 退出抖音 app。\\n操作步骤\\n账号a打开抖音app。\\n点击“消息”。\\n点击“11131群”cell。\\n点击“聊天信息页入口”按钮。\\n点击“分享公开群”按钮。\\n点击文字“群口令”。\\n断言屏幕中存在文字“口令复制成功”。\\n停止操作。\\n注意事项\\n",
StepType: "assert", // Different from automation
DeviceID: deviceInfo.DeviceID,
Base: WingsBase{
LogID: generateWingsUUID(),
},
}
log.Info().Interface("apiRequest", apiRequest).Msg("Wings API request")
// Call Wings API
startTime := time.Now()
@@ -197,7 +175,7 @@ func (w *WingsService) Assert(ctx context.Context, opts *AssertOptions) (*Assert
}
// Check API response status
if response.BaseResp.StatusCode != 0 && response.BaseResp.StatusCode != 200 {
if response.BaseResp.StatusCode != 0 {
err = fmt.Errorf("API returned error: %s", response.BaseResp.StatusMessage)
return &AssertionResult{
Pass: false,
@@ -206,22 +184,6 @@ func (w *WingsService) Assert(ctx context.Context, opts *AssertOptions) (*Assert
}, err
}
// Update history with response data
newHistoryEntry := History{
Observation: response.ThoughtChain.Observation,
Thought: response.ThoughtChain.Thought,
Summary: response.ThoughtChain.Summary,
StepText: response.StepText,
StepTextTrans: response.StepTextTrans,
OriStepIndex: response.OriStepIndex,
DeviceID: deviceInfo[0].DeviceID,
AgentType: response.AgentType,
ActionResult: "", // Always empty as requested
DeviceInfos: &deviceInfo,
ActionParams: response.ActionParams,
}
w.history = append(w.history, newHistoryEntry)
// Parse assertion result from action_params
passed, assertionThought, err := w.parseAssertionResult(response.ActionParams, response.ThoughtChain)
if err != nil {
@@ -232,9 +194,6 @@ func (w *WingsService) Assert(ctx context.Context, opts *AssertOptions) (*Assert
}, errors.Wrap(err, "parse assertion result failed")
}
// No need to update ActionResult as per user request
// ActionResult should always be empty
log.Info().
Bool("passed", passed).
Str("thought", assertionThought).
@@ -269,12 +228,14 @@ func (w *WingsService) RegisterTools(tools []*schema.ToolInfo) error {
// Wings API data structures
type WingsActionRequest struct {
Historys []History `json:"historys"`
DeviceInfo []WingsDeviceInfo `json:"device_infos"`
StepText string `json:"step_text"`
BizId string `json:"biz_id"`
TextCase string `json:"text_case"`
Base WingsBase `json:"Base"`
Historys []interface{} `json:"historys"`
DeviceInfos []WingsDeviceInfo `json:"device_infos"`
StepText string `json:"step_text"`
BizId string `json:"biz_id"`
TextCase string `json:"text_case"`
StepType string `json:"step_type"`
DeviceID string `json:"device_id"`
Base WingsBase `json:"Base"`
}
type WingsDeviceInfo struct {
@@ -292,14 +253,10 @@ type WingsBase struct {
}
type WingsActionResponse struct {
AgentType string `json:"agent_type" thrift:"agent_type,1,required"`
StepText string `json:"step_text" thrift:"step_text,2,required"`
StepTextTrans string `json:"step_text_trans" thrift:"step_text_trans,3,required"`
OriStepIndex int `json:"ori_step_index" thrift:"ori_step_index,4,required"`
StepType string `json:"step_type" thrift:"step_type,5,required"`
ActionParams string `json:"action_params" thrift:"action_params,6,required"`
ThoughtChain WingsThoughtChain `json:"thought_chain" thrift:"thought_chain,7,required"`
BaseResp WingsBaseResp `json:"BaseResp" thrift:"BaseResp,255,optional"`
StepType string `json:"step_type"`
ActionParams string `json:"action_params"`
ThoughtChain WingsThoughtChain `json:"thought_chain"`
BaseResp WingsBaseResp `json:"BaseResp"`
}
type WingsThoughtChain struct {
@@ -319,21 +276,6 @@ type WingsExtra struct {
LogID string `json:"_log_id"`
}
// History structure for request and response
type History struct {
Observation string `json:"observation" thrift:"observation,1,required"` // 思考结果
Thought string `json:"thought" thrift:"thought,2,required"` // 思考结果
Summary string `json:"summary" thrift:"summary,3,required"` // 思考结果
StepText string `json:"step_text" thrift:"step_text,4"` // 操作的指令
DeviceID string `json:"device_id" thrift:"device_id,5"` // 操作的设备id
AgentType string `json:"agent_type" thrift:"agent_type,7"` // 最终决策的agent类型
ActionResult string `json:"action_result" thrift:"action_result,8"` // 操作结果, 断言=断言结果, 自动化=自动化操作是否成功, 物料构造=物料构造结果
DeviceInfos *[]WingsDeviceInfo `json:"device_infos,omitempty" thrift:"device_infos,9"` // 所有设备的信息
ActionParams string `json:"action_params,omitempty" thrift:"action_params,10"` // 历史操作解析结果(断言,自动化,物料构造)
StepTextTrans string `json:"step_text_trans,omitempty" thrift:"step_text_trans,13"` // 归一化的步骤文本(为后续的实际执行解析文本)
OriStepIndex int `json:"ori_step_index,omitempty" thrift:"ori_step_index,14"` // 原本的执行序列(扩展前、目标导向原始文本步骤)
}
// Action parameter structures
type WingsActionParams struct {
Type string `json:"Type"`
@@ -373,11 +315,6 @@ type WingsTextParams struct {
// Helper methods
// resetHistory resets the conversation history
func (w *WingsService) resetHistory() {
w.history = []History{}
}
// generateWingsUUID generates a random UUID for LogID
func generateWingsUUID() string {
return uuid.New().String()
@@ -408,29 +345,19 @@ func (w *WingsService) extractScreenshotFromMessage(message *schema.Message) (st
}
// getDeviceInfoFromContext gets device info from context with fallback
func (w *WingsService) getDeviceInfoFromContext(_ context.Context, screenshot string) []WingsDeviceInfo {
// TODO: Extract device info from context if available
// Use last history's NowImage as PreImage if history exists
preImage := screenshot
if len(w.history) > 0 && w.history[len(w.history)-1].DeviceInfos != nil && len(*w.history[len(w.history)-1].DeviceInfos) > 0 {
preImage = (*w.history[len(w.history)-1].DeviceInfos)[0].NowImage
}
// use default device info with optimized PreImage
return []WingsDeviceInfo{
{
DeviceID: "default-device",
NowImage: screenshot,
PreImage: preImage,
NowLayoutJSON: "",
OperationSystem: "android",
},
func (w *WingsService) getDeviceInfoFromContext(_ context.Context, screenshot string) WingsDeviceInfo {
// use default device info
return WingsDeviceInfo{
DeviceID: "default-device",
NowImage: screenshot,
PreImage: screenshot,
NowLayoutJSON: "",
OperationSystem: "android",
}
}
// getDeviceInfoFromScreenshot gets device info from screenshot (for Assert)
func (w *WingsService) getDeviceInfoFromScreenshot(ctx context.Context, screenshot string) []WingsDeviceInfo {
func (w *WingsService) getDeviceInfoFromScreenshot(ctx context.Context, screenshot string) WingsDeviceInfo {
return w.getDeviceInfoFromContext(ctx, screenshot)
}
@@ -463,8 +390,6 @@ func (w *WingsService) callWingsAPI(ctx context.Context, request WingsActionRequ
// Set headers
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Add("x-use-ppe", "1")
httpReq.Header.Add("x-tt-env", "ppe_refactor_merge")
// Add authentication headers if using external API
if w.accessKey != "" && w.secretKey != "" {
@@ -478,7 +403,7 @@ func (w *WingsService) callWingsAPI(ctx context.Context, request WingsActionRequ
// Execute HTTP request
client := &http.Client{
Timeout: 120 * time.Second,
Timeout: 60 * time.Second,
}
resp, err := client.Do(httpReq)
@@ -486,9 +411,7 @@ func (w *WingsService) callWingsAPI(ctx context.Context, request WingsActionRequ
return nil, errors.Wrap(err, "HTTP request failed")
}
defer resp.Body.Close()
// resp X-Tt-Logid
logID := resp.Header.Get("X-Tt-Logid")
log.Info().Str("step_text", request.StepText).Str("log_id", logID).Str("biz_id", request.BizId).Str("url", w.apiURL).Msg("call wings api")
// Read response body
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
@@ -511,7 +434,7 @@ func (w *WingsService) callWingsAPI(ctx context.Context, request WingsActionRequ
// convertWingsResponseToToolCalls converts Wings API response to tool calls using generic approach
func (w *WingsService) convertWingsResponseToToolCalls(actionParamsStr string) ([]schema.ToolCall, error) {
if actionParamsStr == "" || actionParamsStr == "FINISH" {
if actionParamsStr == "" {
return []schema.ToolCall{}, nil
}

View File

@@ -240,12 +240,12 @@ func (dev *AndroidDevice) installViaInstaller(apkPath string, args ...string) er
return err
}
// 等待安装完成或超时
timeout := 8 * time.Minute
timeout := 3 * time.Minute
select {
case err := <-done:
return err
case <-time.After(timeout):
return fmt.Errorf("install via installer timed out after %v", timeout)
return fmt.Errorf("installation timed out after %v", timeout)
}
}

View File

@@ -706,17 +706,16 @@ func (ad *ADBDriver) StopCaptureLog() (result interface{}, err error) {
log.Error().Err(err).Msg("failed to close adb log writer")
}
pointRes := ConvertPoints(ad.Device.Logcat.logs)
// 没有解析到打点日志,走兜底逻辑
if len(pointRes) == 0 {
log.Info().Msg("action log is null, use action file >>>")
actionLogDirPath := config.GetConfig().ActionLogDirPath()
logFilePathPrefix := fmt.Sprintf("%v/data", actionLogDirPath)
files := []string{}
ad.Device.RunShellCommand("pull", config.DeviceActionLogFilePath, actionLogDirPath)
actionLogRegStr := `.*data_\d+\.txt`
ad.Device.PullFolder(config.DeviceActionLogFilePath, actionLogDirPath)
err = filepath.Walk(actionLogDirPath, func(path string, info fs.FileInfo, err error) error {
// 只是需要日志文件
if ok := strings.Contains(path, logFilePathPrefix); ok {
if ok, _ := regexp.MatchString(actionLogRegStr, path); ok {
files = append(files, path)
}
return nil

View File

@@ -21,6 +21,11 @@ func setupADBDriverExt(t *testing.T) *XTDriver {
Serial: "", // Let it auto-detect the device serial
AIOptions: []option.AIServiceOption{
option.WithCVService(option.CVServiceTypeVEDEM),
option.WithLLMConfig(
option.NewLLMServiceConfig(option.DOUBAO_1_5_UI_TARS_250328).
WithPlannerModel(option.WINGS_SERVICE).
WithAsserterModel(option.WINGS_SERVICE),
),
},
}

View File

@@ -18,6 +18,7 @@ var (
// Ensure drivers implement SIMSupport interface
_ SIMSupport = (*UIA2Driver)(nil)
_ SIMSupport = (*WDADriver)(nil)
)
// current implemeted driver: ADBDriver, UIA2Driver, WDADriver, HDCDriver

View File

@@ -292,14 +292,31 @@ func TestDriverExt_AIAction(t *testing.T) {
func TestDriverExt_AIAction_CompareWithAIAction(t *testing.T) {
driver := setupDriverExt(t)
prompt := "[目标导向]向上滑动屏幕2次"
prompt := "点击搜索按钮"
// Test both methods with the same prompt
aiResult, aiErr := driver.StartToGoal(context.Background(), prompt)
aiResult, aiErr := driver.AIAction(context.Background(), prompt)
// Both should execute without critical errors (may have different implementations)
t.Logf("AIAction error: %v", aiErr)
t.Logf("AIAction result: %v", aiResult)
// If both succeed, compare results
if aiResult != nil {
assert.Equal(t, "action", aiResult.Type, "AIAction result type should be 'action'")
// Both should have timing information
assert.Greater(t, aiResult.ModelCallElapsed, int64(0), "AIAction should have model call elapsed time")
// Both should have screenshot information
assert.NotEmpty(t, aiResult.ImagePath, "AIAction should have image path")
// Compare model names
if aiResult.PlanningResult != nil {
t.Logf("AIAction model: %s", aiResult.PlanningResult.ModelName)
assert.Equal(t, "wings-api", aiResult.PlanningResult.ModelName, "AIAction should use wings-api")
}
}
}
// TestDriverExt_AIAction_ErrorHandling tests AIAction error handling

View File

@@ -284,8 +284,9 @@ func getSimulationDuration(params []float64) (milliseconds int64) {
return 0
}
// sleepStrict sleeps strict duration with given params
// startTime is used to correct sleep duration caused by process time
// sleepStrict sleeps for strict duration with optional start time correction
// If startTime is zero, acts as normal context-aware sleep
// If startTime is provided, corrects sleep duration by subtracting elapsed time
// ctx allows for cancellation during sleep
func sleepStrict(ctx context.Context, startTime time.Time, strictMilliseconds int64) {
var elapsed int64

View File

@@ -24,6 +24,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/internal/simulation"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/httprunner/httprunner/v5/uixt/types"
)
@@ -678,6 +679,13 @@ func (wd *WDADriver) TouchByEvents(events []types.TouchEvent, opts ...option.Act
x, y = toX, toY
}
if x, err = wd.toScale(x); err != nil {
return err
}
if y, err = wd.toScale(y); err != nil {
return err
}
var actionMap map[string]interface{}
switch event.Action {
@@ -743,6 +751,201 @@ func (wd *WDADriver) TouchByEvents(events []types.TouchEvent, opts ...option.Act
return err
}
// SIMSwipeWithDirection 向指定方向滑动任意距离
// direction: 滑动方向 ("up", "down", "left", "right")
// fromX, fromY: 起始坐标
// simMinDistance, simMaxDistance: 距离范围,如果相等则为固定距离,否则为随机距离
func (wd *WDADriver) SIMSwipeWithDirection(direction string, fromX, fromY, simMinDistance, simMaxDistance float64, opts ...option.ActionOption) error {
absStartX, absStartY, err := convertToAbsolutePoint(wd, fromX, fromY)
if err != nil {
return err
}
// 获取设备型号和配置参数
deviceModel := "iphone"
deviceParams := simulation.GetRandomDeviceParams(deviceModel)
log.Info().Str("direction", direction).
Float64("startX", absStartX).Float64("startY", absStartY).
Float64("minDistance", simMinDistance).Float64("maxDistance", simMaxDistance).
Str("deviceModel", deviceModel).
Int("deviceID", deviceParams.DeviceID).
Float64("pressure", deviceParams.Pressure).
Float64("size", deviceParams.Size).
Msg("WDADriver.SIMSwipeWithDirection")
// 导入滑动仿真库
simulator := simulation.NewSlideSimulatorAPI(nil)
// 转换方向字符串为Direction类型
var slideDirection simulation.Direction
switch direction {
case "up":
slideDirection = simulation.Up
case "down":
slideDirection = simulation.Down
case "left":
slideDirection = simulation.Left
case "right":
slideDirection = simulation.Right
default:
return fmt.Errorf("invalid direction: %s, must be one of: up, down, left, right", direction)
}
// 使用滑动仿真算法生成触摸事件序列
events, err := simulator.GenerateSlideWithRandomDistance(
absStartX, absStartY, slideDirection, simMinDistance, simMaxDistance,
deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size)
if err != nil {
return fmt.Errorf("generate slide events failed: %v", err)
}
// 执行触摸事件序列
return wd.TouchByEvents(events, opts...)
}
// SIMSwipeInArea 在指定区域内向指定方向滑动任意距离
// direction: 滑动方向 ("up", "down", "left", "right")
// simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY: 区域范围(相对坐标)
// simMinDistance, simMaxDistance: 距离范围,如果相等则为固定距离,否则为随机距离
func (wd *WDADriver) SIMSwipeInArea(direction string, simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY, simMinDistance, simMaxDistance float64, opts ...option.ActionOption) error {
// 转换区域坐标为绝对坐标
absAreaStartX, absAreaStartY, err := convertToAbsolutePoint(wd, simAreaStartX, simAreaStartY)
if err != nil {
return err
}
absAreaEndX, absAreaEndY, err := convertToAbsolutePoint(wd, simAreaEndX, simAreaEndY)
if err != nil {
return err
}
// 确保区域坐标正确(start应该小于等于end)
if absAreaStartX > absAreaEndX {
absAreaStartX, absAreaEndX = absAreaEndX, absAreaStartX
}
if absAreaStartY > absAreaEndY {
absAreaStartY, absAreaEndY = absAreaEndY, absAreaStartY
}
// 获取设备型号和配置参数
deviceModel := "iphone"
deviceParams := simulation.GetRandomDeviceParams(deviceModel)
log.Info().Str("direction", direction).
Float64("areaStartX", absAreaStartX).Float64("areaStartY", absAreaStartY).
Float64("areaEndX", absAreaEndX).Float64("areaEndY", absAreaEndY).
Float64("minDistance", simMinDistance).Float64("maxDistance", simMaxDistance).
Str("deviceModel", deviceModel).
Int("deviceID", deviceParams.DeviceID).
Float64("pressure", deviceParams.Pressure).
Float64("size", deviceParams.Size).
Msg("WDADriver.SIMSwipeInArea")
// 导入滑动仿真库
simulator := simulation.NewSlideSimulatorAPI(nil)
// 转换方向字符串为Direction类型
var slideDirection simulation.Direction
switch direction {
case "up":
slideDirection = simulation.Up
case "down":
slideDirection = simulation.Down
case "left":
slideDirection = simulation.Left
case "right":
slideDirection = simulation.Right
default:
return fmt.Errorf("invalid direction: %s, must be one of: up, down, left, right", direction)
}
// 使用滑动仿真算法生成区域内滑动的触摸事件序列
events, err := simulator.GenerateSlideInArea(
absAreaStartX, absAreaStartY, absAreaEndX, absAreaEndY,
slideDirection, simMinDistance, simMaxDistance,
deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size)
if err != nil {
return fmt.Errorf("generate slide in area events failed: %v", err)
}
// 执行触摸事件序列
return wd.TouchByEvents(events, opts...)
}
// SIMSwipeFromPointToPoint 指定起始点和结束点进行滑动
// fromX, fromY: 起始坐标(相对坐标)
// toX, toY: 结束坐标(相对坐标)
func (wd *WDADriver) SIMSwipeFromPointToPoint(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
// 转换起始点和结束点为绝对坐标
absStartX, absStartY, err := convertToAbsolutePoint(wd, fromX, fromY)
if err != nil {
return err
}
absEndX, absEndY, err := convertToAbsolutePoint(wd, toX, toY)
if err != nil {
return err
}
// 获取设备型号和配置参数
deviceModel := "iphone"
deviceParams := simulation.GetRandomDeviceParams(deviceModel)
log.Info().Float64("startX", absStartX).Float64("startY", absStartY).
Float64("endX", absEndX).Float64("endY", absEndY).
Str("deviceModel", deviceModel).
Int("deviceID", deviceParams.DeviceID).
Float64("pressure", deviceParams.Pressure).
Float64("size", deviceParams.Size).
Msg("WDADriver.SIMSwipeFromPointToPoint")
// 导入滑动仿真库
simulator := simulation.NewSlideSimulatorAPI(nil)
// 使用滑动仿真算法生成点对点滑动的触摸事件序列
events, err := simulator.GeneratePointToPointSlideEvents(
absStartX, absStartY, absEndX, absEndY,
deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size)
if err != nil {
return fmt.Errorf("generate point to point slide events failed: %v", err)
}
// 执行触摸事件序列
return wd.TouchByEvents(events, opts...)
}
// SIMClickAtPoint 点击相对坐标
// x, y: 点击坐标(相对坐标)
func (wd *WDADriver) SIMClickAtPoint(x, y float64, opts ...option.ActionOption) error {
// 转换为绝对坐标
absX, absY, err := convertToAbsolutePoint(wd, x, y)
if err != nil {
return err
}
// 获取设备型号和配置参数
deviceModel := "iphone"
deviceParams := simulation.GetRandomDeviceParams(deviceModel)
log.Info().Float64("x", absX).Float64("y", absY).
Str("deviceModel", deviceModel).
Int("deviceID", deviceParams.DeviceID).
Float64("pressure", deviceParams.Pressure).
Float64("size", deviceParams.Size).
Msg("WDADriver.SIMClickAtPoint")
// 导入点击仿真库
clickSimulator := simulation.NewClickSimulatorAPI(nil)
// 使用点击仿真算法生成触摸事件序列
events, err := clickSimulator.GenerateClickEvents(
absX, absY, deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size)
if err != nil {
return fmt.Errorf("generate click events failed: %v", err)
}
// 执行触摸事件序列
return wd.TouchByEvents(events, opts...)
}
func (wd *WDADriver) SetPasteboard(contentType types.PasteboardType, content string) (err error) {
// [[FBRoute POST:@"/wda/setPasteboard"] respondWithTarget:self action:@selector(handleSetPasteboard:)]
data := map[string]interface{}{
@@ -784,6 +987,69 @@ func (wd *WDADriver) Input(text string, opts ...option.ActionOption) (err error)
return
}
// SIMInput 仿真输入函数,模拟人类分批输入行为
// 将文本智能分割英文单词和数字保持完整中文按1-2个字符分割
func (wd *WDADriver) SIMInput(text string, opts ...option.ActionOption) error {
log.Info().Str("text", text).Msg("WDADriver.SIMInput")
if text == "" {
return nil
}
// 创建输入仿真器(使用默认配置)
inputSimulator := simulation.NewInputSimulatorAPI(nil)
// 生成输入片段(使用智能分割算法,所有参数使用默认值)
inputReq := simulation.InputRequest{
Text: text,
// MinSegmentLen, MaxSegmentLen, MinDelayMs, MaxDelayMs 使用默认值
}
response := inputSimulator.GenerateInputSegments(inputReq)
if !response.Success {
return fmt.Errorf("failed to generate input segments: %s", response.Message)
}
log.Info().Int("segments", response.Metrics.TotalSegments).
Int("totalDelayMs", response.Metrics.TotalDelayMs).
Int("estimatedTimeMs", response.Metrics.EstimatedTimeMs).
Msg("Input segments generated")
// 逐个输入每个片段
var segmentErrCnt int
for _, segment := range response.Segments {
// 使用Input进行输入内部已包含Session.POST请求
segmentErr := wd.Input(segment.Text, opts...)
if segmentErr != nil {
segmentErrCnt++
log.Info().Err(segmentErr).Int("segmentErrCnt", segmentErrCnt).
Msg("segments err")
}
log.Debug().Str("segment", segment.Text).Int("index", segment.Index).
Int("charLen", segment.CharLen).Msg("Successfully input segment")
// 如果有延迟时间,则等待
if segment.DelayMs > 0 {
time.Sleep(time.Duration(segment.DelayMs) * time.Millisecond)
log.Debug().Int("delayMs", segment.DelayMs).
Msg("Delay between input segments")
}
}
if segmentErrCnt > 0 {
data := map[string]interface{}{"value": strings.Split(text, "")}
option.MergeOptions(data, opts...)
_, err := wd.Session.POST(data, "/wings/interaction/keys")
return err
}
log.Info().Int("totalSegments", response.Metrics.TotalSegments).
Int("actualDelayMs", response.Metrics.TotalDelayMs).
Msg("SIMInput completed successfully")
return nil
}
func (wd *WDADriver) Backspace(count int, opts ...option.ActionOption) (err error) {
log.Info().Int("count", count).Msg("WDADriver.Backspace")
if count == 0 {

View File

@@ -15,7 +15,29 @@ import (
"github.com/httprunner/httprunner/v5/uixt/option"
)
// ToolSleep implements the sleep tool call.
// extractStartTimeMs extracts start_time_ms from MCP request arguments
// Returns time.Time (zero if not provided) and any conversion error
func extractStartTimeMs(request mcp.CallToolRequest) (time.Time, error) {
startTimeMs, ok := request.GetArguments()["start_time_ms"]
if !ok || startTimeMs == nil {
return time.Time{}, nil // Return zero time for normal sleep
}
var ms int64
switch v := startTimeMs.(type) {
case float64:
ms = int64(v)
case int64:
ms = v
case int:
ms = int64(v)
default:
return time.Time{}, fmt.Errorf("invalid start_time_ms type: %T", v)
}
return time.UnixMilli(ms), nil
}
type ToolSleep struct {
// Return data fields - these define the structure of data returned by this tool
Seconds float64 `json:"seconds" desc:"Duration in seconds that was slept"`
@@ -33,6 +55,7 @@ func (t *ToolSleep) Description() string {
func (t *ToolSleep) Options() []mcp.ToolOption {
return []mcp.ToolOption{
mcp.WithNumber("seconds", mcp.Description("Number of seconds to sleep")),
mcp.WithNumber("start_time_ms", mcp.Description("Start time as Unix milliseconds for strict sleep calculation")),
}
}
@@ -70,16 +93,15 @@ func (t *ToolSleep) Implement() server.ToolHandlerFunc {
return nil, fmt.Errorf("unsupported sleep duration type: %T", v)
}
// Use context-aware sleep instead of blocking time.Sleep
select {
case <-time.After(duration):
// Normal completion
case <-ctx.Done():
// 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
// Extract start_time_ms and use sleepStrict for unified sleep logic
startTime, err := extractStartTimeMs(request)
if err != nil {
return nil, err
}
milliseconds := int64(actualSeconds * 1000)
sleepStrict(ctx, startTime, milliseconds)
message := fmt.Sprintf("Successfully slept for %v seconds", actualSeconds)
returnData := ToolSleep{
Seconds: actualSeconds,
@@ -91,9 +113,24 @@ func (t *ToolSleep) Implement() server.ToolHandlerFunc {
}
func (t *ToolSleep) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
arguments := map[string]any{
"seconds": action.Params,
arguments := map[string]any{}
var seconds float64
if param, ok := action.Params.(json.Number); ok {
seconds, _ = param.Float64()
arguments["seconds"] = seconds
} else if param, ok := action.Params.(int64); ok {
seconds = float64(param)
arguments["seconds"] = seconds
} else if sleepConfig, ok := action.Params.(SleepConfig); ok {
// When startTime is provided, pass both seconds and startTime
seconds = sleepConfig.Seconds
arguments["seconds"] = seconds
arguments["start_time_ms"] = sleepConfig.StartTime.UnixMilli()
} else {
return mcp.CallToolRequest{}, fmt.Errorf("invalid sleep params: %v", action.Params)
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}
@@ -115,6 +152,7 @@ func (t *ToolSleepMS) Description() string {
func (t *ToolSleepMS) Options() []mcp.ToolOption {
return []mcp.ToolOption{
mcp.WithNumber("milliseconds", mcp.Description("Number of milliseconds to sleep")),
mcp.WithNumber("start_time_ms", mcp.Description("Start time as Unix milliseconds for strict sleep calculation")),
}
}
@@ -152,16 +190,14 @@ func (t *ToolSleepMS) Implement() server.ToolHandlerFunc {
return nil, fmt.Errorf("unsupported sleep duration type: %T", v)
}
// Use context-aware sleep instead of blocking time.Sleep
select {
case <-time.After(duration):
// Normal completion
case <-ctx.Done():
// 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
// Extract start_time_ms and use sleepStrict for unified sleep logic
startTime, err := extractStartTimeMs(request)
if err != nil {
return nil, err
}
sleepStrict(ctx, startTime, actualMilliseconds)
message := fmt.Sprintf("Successfully slept for %d milliseconds", actualMilliseconds)
returnData := ToolSleepMS{
Milliseconds: actualMilliseconds,
@@ -173,17 +209,24 @@ func (t *ToolSleepMS) Implement() server.ToolHandlerFunc {
}
func (t *ToolSleepMS) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
arguments := map[string]any{}
var milliseconds int64
if param, ok := action.Params.(json.Number); ok {
milliseconds, _ = param.Int64()
arguments["milliseconds"] = milliseconds
} else if param, ok := action.Params.(int64); ok {
milliseconds = param
arguments["milliseconds"] = milliseconds
} else if sleepConfig, ok := action.Params.(SleepConfig); ok {
// When startTime is provided, pass both milliseconds and startTime
milliseconds = sleepConfig.Milliseconds
arguments["milliseconds"] = milliseconds
arguments["start_time_ms"] = sleepConfig.StartTime.UnixMilli()
} else {
return mcp.CallToolRequest{}, fmt.Errorf("invalid sleep ms params: %v", action.Params)
}
arguments := map[string]any{
"milliseconds": milliseconds,
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}

View File

@@ -0,0 +1,240 @@
package uixt
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/httprunner/httprunner/v5/uixt/option"
)
func TestToolSleep_ConvertActionToCallToolRequest(t *testing.T) {
tool := &ToolSleep{}
tests := []struct {
name string
action option.MobileAction
expectedArgs map[string]any
shouldError bool
}{
{
name: "json.Number parameter",
action: option.MobileAction{
Method: option.ACTION_Sleep,
Params: json.Number("3.5"),
},
expectedArgs: map[string]any{"seconds": float64(3.5)},
shouldError: false,
},
{
name: "int64 parameter",
action: option.MobileAction{
Method: option.ACTION_Sleep,
Params: int64(5),
},
expectedArgs: map[string]any{"seconds": float64(5)},
shouldError: false,
},
{
name: "SleepConfig with startTime",
action: option.MobileAction{
Method: option.ACTION_Sleep,
Params: SleepConfig{
StartTime: time.UnixMilli(1691234567890),
Seconds: 2.5,
},
},
expectedArgs: map[string]any{
"seconds": 2.5,
"start_time_ms": int64(1691234567890),
},
shouldError: false,
},
{
name: "invalid parameter type",
action: option.MobileAction{
Method: option.ACTION_Sleep,
Params: "invalid",
},
expectedArgs: nil,
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request, err := tool.ConvertActionToCallToolRequest(tt.action)
if tt.shouldError {
assert.Error(t, err)
} else {
require.NoError(t, err)
args := request.GetArguments()
for key, expectedValue := range tt.expectedArgs {
assert.Equal(t, expectedValue, args[key], "Argument %s mismatch", key)
}
}
})
}
}
func TestToolSleepMS_ConvertActionToCallToolRequest(t *testing.T) {
tool := &ToolSleepMS{}
tests := []struct {
name string
action option.MobileAction
expectedArgs map[string]any
shouldError bool
}{
{
name: "json.Number parameter",
action: option.MobileAction{
Method: option.ACTION_SleepMS,
Params: json.Number("1500"),
},
expectedArgs: map[string]any{"milliseconds": int64(1500)},
shouldError: false,
},
{
name: "int64 parameter",
action: option.MobileAction{
Method: option.ACTION_SleepMS,
Params: int64(2000),
},
expectedArgs: map[string]any{"milliseconds": int64(2000)},
shouldError: false,
},
{
name: "SleepConfig with startTime",
action: option.MobileAction{
Method: option.ACTION_SleepMS,
Params: SleepConfig{
StartTime: time.UnixMilli(1691234567890),
Milliseconds: 3000,
},
},
expectedArgs: map[string]any{
"milliseconds": int64(3000),
"start_time_ms": int64(1691234567890),
},
shouldError: false,
},
{
name: "invalid parameter type",
action: option.MobileAction{
Method: option.ACTION_SleepMS,
Params: "invalid",
},
expectedArgs: nil,
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request, err := tool.ConvertActionToCallToolRequest(tt.action)
if tt.shouldError {
assert.Error(t, err)
} else {
require.NoError(t, err)
args := request.GetArguments()
for key, expectedValue := range tt.expectedArgs {
assert.Equal(t, expectedValue, args[key], "Argument %s mismatch", key)
}
}
})
}
}
func TestSleepStrictTiming(t *testing.T) {
// Test that strict sleep properly adjusts for elapsed time
startTime := time.Now()
// Simulate some processing time
time.Sleep(50 * time.Millisecond)
ctx := context.Background()
// Test sleepStrict with the start time
testStart := time.Now()
sleepStrict(ctx, startTime, 200) // 200ms total duration
actualElapsed := time.Since(testStart)
// Should sleep approximately 150ms (200ms - 50ms already elapsed)
// Allow some tolerance for timing variations
expectedSleep := 150 * time.Millisecond
assert.Greater(t, actualElapsed, expectedSleep/2, "Sleep too short")
assert.Less(t, actualElapsed, expectedSleep*2, "Sleep too long")
}
func TestSleepCancellation(t *testing.T) {
// Test that sleep respects context cancellation
ctx, cancel := context.WithCancel(context.Background())
// Cancel after 50ms
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
start := time.Now()
sleepStrict(ctx, time.Time{}, 500) // Try to sleep 500ms
elapsed := time.Since(start)
// Should be cancelled after ~50ms, not sleep full 500ms
assert.Less(t, elapsed, 200*time.Millisecond, "Sleep was not properly cancelled")
}
func TestSleepStrictWithZeroTime(t *testing.T) {
// Test sleepStrict behaves like normal sleep when startTime is zero
ctx := context.Background()
start := time.Now()
sleepStrict(ctx, time.Time{}, 100) // 100ms, no start time
elapsed := time.Since(start)
// Should sleep full duration
expectedSleep := 100 * time.Millisecond
assert.Greater(t, elapsed, expectedSleep/2, "Sleep too short")
assert.Less(t, elapsed, expectedSleep*2, "Sleep too long")
}
func TestSleepStrictWithPastStartTime(t *testing.T) {
// Test sleepStrict skips sleep when elapsed time exceeds duration
startTime := time.Now().Add(-300 * time.Millisecond) // 300ms ago
ctx := context.Background()
start := time.Now()
sleepStrict(ctx, startTime, 200) // Want 200ms total, but 300ms already elapsed
elapsed := time.Since(start)
// Should skip sleep entirely
assert.Less(t, elapsed, 50*time.Millisecond, "Should have skipped sleep")
}
func TestJsonNumberHandling(t *testing.T) {
// Test that json.Number is correctly handled in different scenarios
// Test float json.Number
floatNumber := json.Number("3.14")
floatVal, err := floatNumber.Float64()
assert.NoError(t, err)
assert.Equal(t, 3.14, floatVal)
// Test int json.Number
intNumber := json.Number("1500")
intVal, err := intNumber.Int64()
assert.NoError(t, err)
assert.Equal(t, int64(1500), intVal)
// Test invalid json.Number
invalidNumber := json.Number("invalid")
_, err = invalidNumber.Float64()
assert.Error(t, err)
}

View File

@@ -66,22 +66,21 @@ func ParseTouchEvents(data string) ([]types.TouchEvent, error) {
if event.Action, err = strconv.Atoi(parts[12]); err != nil {
return nil, fmt.Errorf("invalid action: %v", err)
}
events = append(events, event)
}
return events, nil
}
func TestAndroidTouchByEvents(t *testing.T) {
device, err := NewAndroidDevice(
option.WithSerialNumber(""),
func TestIOSTouchByEvents(t *testing.T) {
device, err := NewIOSDevice(
option.WithUDID(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := NewUIA2Driver(device)
driver, err := NewWDADriver(device)
if err != nil {
t.Fatal(err)
}
@@ -138,60 +137,6 @@ func TestAndroidTouchByEvents(t *testing.T) {
t.Logf("Successfully executed touch events: %d events processed", len(events))
}
func TestIOSTouchByEvents(t *testing.T) {
driver := setupWDADriverExt(t)
// Example touch event data as provided
touchEventData := `1752649131556,401.20703,1191.3164,2,1.0,0.03529412,457.20703,1359.3164,111586196,111586196,1,0,0
1752649131595,402.913,1185.0792,2,1.0,0.039215688,458.913,1353.0792,111586196,111586236,1,0,2
1752649131612,410.60825,1164.3806,2,1.0,0.03529412,466.60825,1332.3806,111586196,111586250,1,0,2
1752649131629,437.7335,1093.1417,2,1.0,0.039215688,493.7335,1261.1417,111586196,111586270,1,0,2
1752649131646,463.5786,1018.01746,2,1.0,0.039215688,519.5786,1186.0175,111586196,111586287,1,0,2
1752649131662,487.56482,948.9773,2,1.0,0.03529412,543.5648,1116.9773,111586196,111586304,1,0,2
1752649131679,511.81476,881.6183,2,1.0,0.039215688,567.81476,1049.6183,111586196,111586320,1,0,2
1752649131696,543.4369,811.4982,2,1.0,0.03529412,599.4369,979.4982,111586196,111586337,1,0,2
1752649131713,577.1632,747.4512,2,1.0,0.039215688,633.1632,915.4512,111586196,111586354,1,0,2
1752649131729,610.1538,691.72034,2,1.0,0.03529412,666.1538,859.72034,111586196,111586370,1,0,2
1752649131746,639.1683,642.6914,2,1.0,0.03529412,695.1683,810.6914,111586196,111586387,1,0,2
1752649131763,658.9832,605.90857,2,1.0,0.03529412,714.9832,773.90857,111586196,111586404,1,0,2
1752649131779,672.21954,581.1634,2,1.0,0.03529412,728.21954,749.1634,111586196,111586420,1,0,2
1752649131796,680.7687,566.1778,2,1.0,0.03529412,736.7687,734.1778,111586196,111586434,1,0,2
1752649131814,688.0894,554.2295,2,1.0,0.03529412,744.0894,722.2295,111586196,111586450,1,0,2
1752649131830,694.542,544.7783,2,1.0,0.03529412,750.542,712.7783,111586196,111586466,1,0,2
1752649131847,700.60645,537.2637,2,1.0,0.039215688,756.60645,705.2637,111586196,111586483,1,0,2
1752649131863,705.08887,531.1406,2,1.0,0.039215688,761.08887,699.1406,111586196,111586500,1,0,2
1752649131880,708.1211,527.8008,2,1.0,0.039215688,764.1211,695.8008,111586196,111586517,1,0,2
1752649131897,709.43945,524.46094,2,1.0,0.039215688,765.43945,692.46094,111586196,111586533,1,0,2
1752649131902,709.1758,523.34766,2,1.0,0.03529412,765.1758,691.34766,111586196,111586537,1,33554432,2
1752649131907,709.1758,523.34766,2,1.0,0.03529412,765.1758,691.34766,111586196,111586546,1,0,1`
// Parse touch events
events, err := ParseTouchEvents(touchEventData)
if err != nil {
t.Fatalf("ParseTouchEvents failed: %v", err)
}
// Check first event
firstEvent := events[0]
if firstEvent.Action != 0 { // ACTION_DOWN
t.Errorf("Expected first event action to be 0 (ACTION_DOWN), got %d", firstEvent.Action)
}
// Check last event
lastEvent := events[len(events)-1]
if lastEvent.Action != 1 { // ACTION_UP
t.Errorf("Expected last event action to be 1 (ACTION_UP), got %d", lastEvent.Action)
}
// Use TouchByEvents with parsed events
err = driver.IDriver.(*WDADriver).TouchByEvents(events)
if err != nil {
t.Fatalf("TouchByEvents failed: %v", err)
}
t.Logf("Successfully executed touch events: %d events processed", len(events))
}
func TestTouchEventParsing(t *testing.T) {
// Test single touch event parsing
singleEventData := "1752646457403,456.78418,1574.0195,7,1.0,0.016666668,504.78418,1721.0195,924451292,924451292,1,0,0"
@@ -281,14 +226,14 @@ func TestTouchEventSequenceValidation(t *testing.T) {
}
func TestSwipeWithDirection(t *testing.T) {
device, err := NewAndroidDevice(
option.WithSerialNumber(""),
device, err := NewIOSDevice(
option.WithUDID(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := NewUIA2Driver(device)
driver, err := NewWDADriver(device)
if err != nil {
t.Fatal(err)
}
@@ -308,7 +253,7 @@ func TestSwipeWithDirection(t *testing.T) {
direction: "up",
startX: 0.5,
startY: 0.5,
minDistance: 100.0,
minDistance: 500.0,
maxDistance: 500.0,
},
}
@@ -332,50 +277,15 @@ func TestSwipeWithDirection(t *testing.T) {
}
}
func TestSwipeWithDirectionInvalidInputs(t *testing.T) {
device, err := NewAndroidDevice(
option.WithSerialNumber(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := NewUIA2Driver(device)
if err != nil {
t.Fatal(err)
}
defer driver.TearDown()
// Test invalid direction
err = driver.SIMSwipeWithDirection("invalid", 500.0, 500.0, 100.0, 200.0)
if err == nil {
t.Error("Expected error for invalid direction, but got none")
}
// Test invalid distance range (max < min)
err = driver.SIMSwipeWithDirection("up", 500.0, 500.0, 200.0, 100.0)
if err == nil {
t.Error("Expected error for invalid distance range, but got none")
}
// Test zero distance
err = driver.SIMSwipeWithDirection("up", 500.0, 500.0, 0.0, 0.0)
if err == nil {
t.Error("Expected error for zero distance, but got none")
}
t.Log("Invalid input validation tests passed")
}
func TestSwipeInArea(t *testing.T) {
device, err := NewAndroidDevice(
option.WithSerialNumber(""),
device, err := NewIOSDevice(
option.WithUDID(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := NewUIA2Driver(device)
driver, err := NewWDADriver(device)
if err != nil {
t.Fatal(err)
}
@@ -428,14 +338,14 @@ func TestSwipeInArea(t *testing.T) {
}
func TestSwipeFromPointToPoint(t *testing.T) {
device, err := NewAndroidDevice(
option.WithSerialNumber(""),
device, err := NewIOSDevice(
option.WithUDID(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := NewUIA2Driver(device)
driver, err := NewWDADriver(device)
if err != nil {
t.Fatal(err)
}
@@ -477,14 +387,14 @@ func TestSwipeFromPointToPoint(t *testing.T) {
}
func TestSwipeFromPointToPointInvalidInputs(t *testing.T) {
device, err := NewAndroidDevice(
option.WithSerialNumber(""),
device, err := NewIOSDevice(
option.WithUDID(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := NewUIA2Driver(device)
driver, err := NewWDADriver(device)
if err != nil {
t.Fatal(err)
}
@@ -506,14 +416,14 @@ func TestSwipeFromPointToPointInvalidInputs(t *testing.T) {
}
func TestClickAtPoint(t *testing.T) {
device, err := NewAndroidDevice(
option.WithSerialNumber(""),
device, err := NewIOSDevice(
option.WithUDID(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := NewUIA2Driver(device)
driver, err := NewWDADriver(device)
if err != nil {
t.Fatal(err)
}
@@ -546,14 +456,14 @@ func TestClickAtPoint(t *testing.T) {
}
func TestClickAtPointInvalidInputs(t *testing.T) {
device, err := NewAndroidDevice(
option.WithSerialNumber(""),
device, err := NewIOSDevice(
option.WithUDID(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := NewUIA2Driver(device)
driver, err := NewWDADriver(device)
if err != nil {
t.Fatal(err)
}
@@ -580,14 +490,14 @@ func TestClickAtPointInvalidInputs(t *testing.T) {
}
func TestSIMInput(t *testing.T) {
device, err := NewAndroidDevice(
option.WithSerialNumber(""),
device, err := NewIOSDevice(
option.WithUDID(""),
)
if err != nil {
t.Fatal(err)
}
driver, err := NewUIA2Driver(device)
driver, err := NewWDADriver(device)
if err != nil {
t.Fatal(err)
}