Merge branch 'master' into session_refactor

This commit is contained in:
余泓铮
2025-08-15 11:21:52 +08:00
68 changed files with 2027 additions and 1531 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

132
CLAUDE.md Normal file
View File

@@ -0,0 +1,132 @@
# 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
### Code Standards
- All code comments must be written in English
- All documentation must be written in Chinese

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

@@ -7,13 +7,14 @@ import (
"fmt"
"path/filepath"
"github.com/rs/zerolog/log"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/uixt"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/rs/zerolog/log"
)
// GameElement represents a game element detected in the interface
@@ -34,6 +35,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

@@ -217,6 +217,8 @@ func Interface2Float64(i interface{}) (float64, error) {
case string: // e.g. "1", "0.5"
floatVar, err := strconv.ParseFloat(v, 64)
if err != nil {
log.Error().Err(err).Str("value", v).
Msg("convert string to float64 failed")
return 0, err
}
return floatVar, nil
@@ -226,6 +228,10 @@ func Interface2Float64(i interface{}) (float64, error) {
if ok {
return value.Float64()
}
// Log error for unsupported types
log.Error().Interface("value", i).Type("type", i).
Msg("convert float64 failed")
return 0, errors.New("failed to convert interface to float64")
}
@@ -334,29 +340,6 @@ func IsZeroFloat64(f float64) bool {
return math.Abs(f) < threshold
}
func ConvertToFloat64(val interface{}) (float64, error) {
switch v := val.(type) {
case float64:
return v, nil
case int:
return float64(v), nil
case int64:
return float64(v), nil
case string:
f, err := strconv.ParseFloat(v, 64)
if err != nil {
log.Error().Err(err).Str("value", v).
Msg("convert string to float64 failed")
return 0, err
}
return f, nil
default:
log.Error().Interface("value", val).Type("type", val).
Msg("convert float64 failed")
return 0, errors.New("convert float64 error")
}
}
func ConvertToFloat64Slice(val interface{}) ([]float64, error) {
if paramsSlice, ok := val.([]float64); ok {
return paramsSlice, nil
@@ -369,7 +352,7 @@ func ConvertToFloat64Slice(val interface{}) ([]float64, error) {
var err error
float64Slice := make([]float64, len(paramsSlice))
for i, v := range paramsSlice {
float64Slice[i], err = ConvertToFloat64(v)
float64Slice[i], err = Interface2Float64(v)
if err != nil {
return nil, 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

@@ -1 +1 @@
v5.0.0-250814
v5.0.0-250815

View File

@@ -18,7 +18,11 @@ func InitLogger(logLevel string, logJSON bool, logFile bool) {
// Error Logging with Stacktrace
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
// set log timestamp precise to milliseconds
// set log timestamp precise to milliseconds with Beijing timezone (UTC+8)
beijingLoc, _ := time.LoadLocation("Asia/Shanghai")
zerolog.TimestampFunc = func() time.Time {
return time.Now().In(beijingLoc)
}
zerolog.TimeFieldFormat = "2006-01-02T15:04:05.999Z0700"
// init log writers

View File

@@ -610,6 +610,55 @@ 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

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

@@ -67,6 +67,7 @@ type ActionResult struct {
Plannings []*uixt.PlanningExecutionResult `json:"plannings,omitempty"` // store planning results for start_to_goal actions, which contains multiple sub-actions
AIResult *uixt.AIExecutionResult `json:"ai_result,omitempty"` // store unified AI execution result for ai_query/ai_action/ai_assert actions
uixt.SessionData // store session data for other actions besides start_to_goal
ExtraData interface{} `json:"extra_data,omitempty"`
}
// one testcase contains one or multiple steps

View File

@@ -1070,6 +1070,19 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
}
continue
}
if action.Method == option.ACTION_GetPasteboard {
content, err := uiDriver.GetPasteboard()
if err != nil {
actionResult.Error = err.Error()
if !code.IsErrorPredefined(err) {
err = errors.Wrap(code.MobileUIDriverError, err.Error())
}
return stepResult, err
}
actionResult.ExtraData = content
stepResult.Actions = append(stepResult.Actions, actionResult)
continue
}
// handle other non-AI actions
sessionData, err := uiDriver.ExecuteAction(ctx, action)

View File

@@ -1,35 +1,19 @@
//go:build localtest
package ai
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/stretchr/testify/assert"
"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)
require.NoError(t, err)
@@ -96,11 +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)
require.NoError(t, err)

View File

@@ -482,9 +482,10 @@ 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 {

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

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

@@ -100,8 +100,7 @@ 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), option.WithInterval(1))
opts = append([]option.ActionOption{option.WithMatchOne(true), option.WithRegex(true), option.WithInterval(1)}, opts...)
// Remove identifier for swipe operations to avoid WDA/UIA2 logging
actionOptions := option.NewActionOptions(opts...)
actionOptions.Identifier = ""

View File

@@ -270,8 +270,10 @@ func (s *DriverSession) Request(method string, urlStr string, rawBody []byte, op
logger = log.Debug().Bool("success", true)
}
logger = logger.Str("logid", logid).Str("request_method", method).Str("request_url", rawURL).
Str("request_body", string(rawBody))
logger = logger.Str("logid", logid).Str("request_method", method).Str("request_url", rawURL)
if len(rawBody) < 1024 {
logger = logger.Str("request_body", string(rawBody))
}
if !driverResult.RequestTime.IsZero() {
logger = logger.Int64("request_time", driverResult.RequestTime.UnixMilli())
}

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

116
uixt/image_utils.go Normal file
View File

@@ -0,0 +1,116 @@
package uixt
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/rs/zerolog/log"
)
// DetectAndRenameImageFile examines the file content to determine its media type
// and renames the file with the appropriate extension (.jpg, .png, .mp4, etc.)
func DetectAndRenameMediaFile(filePath string) (string, error) {
// Open the file
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file for type detection: %v", err)
}
defer file.Close()
// Read the first 512 bytes to detect content type
buffer := make([]byte, 512)
_, err = file.Read(buffer)
if err != nil && err != io.EOF {
return "", fmt.Errorf("failed to read file for type detection: %v", err)
}
// Reset file pointer
_, err = file.Seek(0, 0)
if err != nil {
return "", fmt.Errorf("failed to reset file pointer: %v", err)
}
// Detect content type
contentType := http.DetectContentType(buffer)
log.Info().Str("filePath", filePath).Str("contentType", contentType).Msg("Detected content type")
// Determine file extension based on content type
var extension string
switch {
// Image types
case strings.Contains(contentType, "image/jpeg"):
extension = ".jpg"
case strings.Contains(contentType, "image/png"):
extension = ".png"
case strings.Contains(contentType, "image/gif"):
extension = ".gif"
case strings.Contains(contentType, "image/webp"):
extension = ".webp"
case strings.Contains(contentType, "image/bmp"):
extension = ".bmp"
case strings.Contains(contentType, "image/tiff"):
extension = ".tiff"
case strings.Contains(contentType, "image/svg+xml"):
extension = ".svg"
// Video types
case strings.Contains(contentType, "video/mp4"):
extension = ".mp4"
case strings.Contains(contentType, "video/quicktime"):
extension = ".mov"
case strings.Contains(contentType, "video/x-msvideo"):
extension = ".avi"
case strings.Contains(contentType, "video/x-ms-wmv"):
extension = ".wmv"
case strings.Contains(contentType, "video/x-flv"):
extension = ".flv"
case strings.Contains(contentType, "video/webm"):
extension = ".webm"
case strings.Contains(contentType, "video/x-matroska"):
extension = ".mkv"
default:
// Check for general image or video types
if strings.Contains(contentType, "image/") {
extension = ".jpg" // Default for unknown image types
} else if strings.Contains(contentType, "video/") {
extension = ".mp4" // Default for unknown video types
} else {
// Try to determine from original file extension
origExt := strings.ToLower(filepath.Ext(filePath))
if origExt == ".mp4" || origExt == ".mov" || origExt == ".avi" ||
origExt == ".wmv" || origExt == ".flv" || origExt == ".webm" || origExt == ".mkv" {
extension = origExt
} else if origExt == ".jpg" || origExt == ".jpeg" || origExt == ".png" ||
origExt == ".gif" || origExt == ".webp" || origExt == ".bmp" ||
origExt == ".tiff" || origExt == ".svg" {
extension = origExt
} else {
return filePath, fmt.Errorf("not a recognized media type: %s", contentType)
}
}
}
// Create new file path with extension
dir := filepath.Dir(filePath)
base := filepath.Base(filePath)
newFilePath := filepath.Join(dir, base+extension)
// If the file already has the correct extension, just return it
if filePath == newFilePath {
return filePath, nil
}
// Rename the file
err = os.Rename(filePath, newFilePath)
if err != nil {
return "", fmt.Errorf("failed to rename file: %v", err)
}
log.Info().Str("oldPath", filePath).Str("newPath", newFilePath).Msg("Renamed image file with proper extension")
return newFilePath, nil
}

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"
)
@@ -692,6 +693,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 {
@@ -757,6 +765,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{}{
@@ -798,6 +1001,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

@@ -133,6 +133,10 @@ func (s *MCPServer4XTDriver) registerTools() {
s.registerTool(&ToolGetScreenSize{})
s.registerTool(&ToolGetSource{})
// Media Album Tools
s.registerTool(&ToolPushAlbums{})
s.registerTool(&ToolClearAlbums{})
// Utility Tools
s.registerTool(&ToolSleep{})
s.registerTool(&ToolSleepMS{})
@@ -297,7 +301,7 @@ func extractActionOptionsToArguments(actionOptions []option.ActionOption, argume
// Add tap/swipe offset options
if len(tempOptions.TapOffset) == 2 {
arguments["tap_offset"] = tempOptions.TapOffset
arguments["offset"] = tempOptions.TapOffset
}
if len(tempOptions.SwipeOffset) == 4 {
arguments["swipe_offset"] = tempOptions.SwipeOffset

View File

@@ -169,27 +169,106 @@ func TestIgnoreNotFoundErrorOption(t *testing.T) {
func TestExtractActionOptionsToArguments(t *testing.T) {
// Test the extractActionOptionsToArguments helper function
actionOptions := []option.ActionOption{
// Boolean options
option.WithIgnoreNotFoundError(true),
option.WithMaxRetryTimes(3),
option.WithIndex(2),
option.WithRegex(true),
option.WithTapRandomRect(false), // false should not be included
option.WithDuration(1.5),
option.WithAntiRisk(true),
option.WithPreMarkOperation(true),
option.WithResetHistory(true),
option.WithMatchOne(true),
// Numeric options
option.WithMaxRetryTimes(3),
option.WithIndex(2),
option.WithInterval(1.5),
option.WithSteps(10),
option.WithTimeout(30),
option.WithFrequency(5),
option.WithDuration(2.0),
option.WithPressDuration(1.5),
// Offset options (including the fixed offset field)
option.WithTapOffset(-300, 0),
option.WithSwipeOffset(1, 2, 3, 4),
option.WithOffsetRandomRange(-5, 5),
// Scope options
option.WithScope(0.1, 0.2, 0.9, 0.8),
option.WithAbsScope(100, 200, 900, 800),
// Screenshot options
option.WithScreenShotOCR(true),
option.WithScreenShotUpload(true),
option.WithScreenShotLiveType(true),
option.WithScreenShotLivePopularity(true),
option.WithScreenShotClosePopups(true),
option.WithScreenOCRCluster("test_cluster"),
option.WithScreenShotFileName("test.png"),
option.WithScreenShotUITypes("button", "input"),
// Direction option
option.WithDirection("up"),
// Identifier
option.WithIdentifier("test_id"),
}
arguments := make(map[string]any)
extractActionOptionsToArguments(actionOptions, arguments)
// Verify extracted options
// Verify boolean options (only true values should be included)
assert.Equal(t, true, arguments["ignore_NotFoundError"], "ignore_NotFoundError should be extracted")
assert.Equal(t, 3, arguments["max_retry_times"], "max_retry_times should be extracted")
assert.Equal(t, 2, arguments["index"], "index should be extracted")
assert.Equal(t, true, arguments["regex"], "regex should be extracted")
assert.Equal(t, 1.5, arguments["duration"], "duration should be extracted")
assert.Equal(t, true, arguments["anti_risk"], "anti_risk should be extracted")
assert.Equal(t, true, arguments["pre_mark_operation"], "pre_mark_operation should be extracted")
assert.Equal(t, true, arguments["reset_history"], "reset_history should be extracted")
assert.Equal(t, true, arguments["match_one"], "match_one should be extracted")
// tap_random_rect should not be included since it's false
_, exists := arguments["tap_random_rect"]
assert.False(t, exists, "tap_random_rect should not be included when false")
// Verify numeric options
assert.Equal(t, 3, arguments["max_retry_times"], "max_retry_times should be extracted")
assert.Equal(t, 2, arguments["index"], "index should be extracted")
assert.Equal(t, 1.5, arguments["interval"], "interval should be extracted")
assert.Equal(t, 10, arguments["steps"], "steps should be extracted")
assert.Equal(t, 30, arguments["timeout"], "timeout should be extracted")
assert.Equal(t, 5, arguments["frequency"], "frequency should be extracted")
assert.Equal(t, 2.0, arguments["duration"], "duration should be extracted")
assert.Equal(t, 1.5, arguments["press_duration"], "press_duration should be extracted")
// Verify offset options (including the critical 'offset' field that was fixed)
assert.Equal(t, []int{-300, 0}, arguments["offset"], "offset should be extracted (not tap_offset)")
assert.Equal(t, []int{1, 2, 3, 4}, arguments["swipe_offset"], "swipe_offset should be extracted")
assert.Equal(t, []int{-5, 5}, arguments["offset_random_range"], "offset_random_range should be extracted")
// Verify scope options (these are custom types, not raw slices)
assert.Equal(t, option.Scope([]float64{0.1, 0.2, 0.9, 0.8}), arguments["scope"], "scope should be extracted")
assert.Equal(t, option.AbsScope([]int{100, 200, 900, 800}), arguments["abs_scope"], "abs_scope should be extracted")
// Verify screenshot options
assert.Equal(t, true, arguments["screenshot_with_ocr"], "screenshot_with_ocr should be extracted")
assert.Equal(t, true, arguments["screenshot_with_upload"], "screenshot_with_upload should be extracted")
assert.Equal(t, true, arguments["screenshot_with_live_type"], "screenshot_with_live_type should be extracted")
assert.Equal(t, true, arguments["screenshot_with_live_popularity"], "screenshot_with_live_popularity should be extracted")
assert.Equal(t, true, arguments["screenshot_with_close_popups"], "screenshot_with_close_popups should be extracted")
assert.Equal(t, "test_cluster", arguments["screenshot_with_ocr_cluster"], "screenshot_with_ocr_cluster should be extracted")
assert.Equal(t, "test.png", arguments["screenshot_file_name"], "screenshot_file_name should be extracted")
assert.Equal(t, []string{"button", "input"}, arguments["screenshot_with_ui_types"], "screenshot_with_ui_types should be extracted")
// Verify identifier and direction (only fields that exist)
assert.Equal(t, "test_id", arguments["identifier"], "identifier should be extracted")
assert.Equal(t, "up", arguments["direction"], "direction should be extracted")
// Verify the critical fix: ensure "offset" is used instead of "tap_offset"
_, hasTapOffset := arguments["tap_offset"]
assert.False(t, hasTapOffset, "Should NOT contain 'tap_offset' field")
_, hasOffset := arguments["offset"]
assert.True(t, hasOffset, "Should contain 'offset' field")
t.Logf("Extracted %d arguments from ActionOptions", len(arguments))
}
// TestToolListAvailableDevices tests the ToolListAvailableDevices implementation

View File

@@ -3,6 +3,9 @@ package uixt
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/danielpaulus/go-ios/ios"
"github.com/mark3labs/mcp-go/mcp"
@@ -216,3 +219,198 @@ func (t *ToolScreenRecord) Implement() server.ToolHandlerFunc {
func (t *ToolScreenRecord) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
return BuildMCPCallToolRequest(t.Name(), map[string]any{}, action), nil
}
// ToolPushAlbums implements the push_albums tool call.
type ToolPushAlbums struct {
// Return data fields - these define the structure of data returned by this tool
FilePath string `json:"filePath" desc:"Path of the file that was pushed"`
FileUrl string `json:"fileUrl,omitempty" desc:"URL of the file that was downloaded and pushed (if applicable)"`
FileType string `json:"fileType" desc:"Type of the file that was pushed (image or video)"`
Cleared bool `json:"cleared,omitempty" desc:"Whether albums were cleared before pushing (if applicable)"`
}
func (t *ToolPushAlbums) Name() option.ActionName {
return option.ACTION_PushAlbums
}
func (t *ToolPushAlbums) Description() string {
return "Push a media file (image or video) to the device's gallery. For Android, this will push the file to the DCIM/Camera directory. For iOS, this will add the file to the photo album."
}
func (t *ToolPushAlbums) Options() []mcp.ToolOption {
return []mcp.ToolOption{
mcp.WithString("platform", mcp.Enum("android", "ios"), mcp.Description("The platform type of device to push media to")),
mcp.WithString("serial", mcp.Description("The device serial number or UDID")),
mcp.WithString("filePath", mcp.Description("Path to the local media file to push to the device")),
mcp.WithString("fileUrl", mcp.Description("URL of the media file to download and push to the device")),
mcp.WithBoolean("cleanup", mcp.Description("Whether to delete the downloaded file after pushing it to the device")),
mcp.WithBoolean("clearBefore", mcp.Description("Whether to clear albums before pushing (if applicable)")),
}
}
func (t *ToolPushAlbums) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
arguments := map[string]any{}
// Handle string param as fileUrl
if fileUrl, ok := action.Params.(string); ok && fileUrl != "" {
arguments["fileUrl"] = fileUrl
}
// Handle map params with fileUrl or filePath
if params, ok := action.Params.(map[string]interface{}); ok {
if fileUrl, ok := params["fileUrl"].(string); ok && fileUrl != "" {
arguments["fileUrl"] = fileUrl
}
if filePath, ok := params["filePath"].(string); ok && filePath != "" {
arguments["filePath"] = filePath
}
if cleanup, ok := params["cleanup"].(bool); ok {
arguments["cleanup"] = cleanup
}
if clearBefore, ok := params["clearBefore"].(bool); ok {
arguments["clearBefore"] = clearBefore
}
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}
func (t *ToolPushAlbums) Implement() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
driverExt, err := setupXTDriver(ctx, request.GetArguments())
if err != nil {
return nil, err
}
// Get file path or URL
filePath, hasPath := request.GetArguments()["filePath"].(string)
fileUrl, hasUrl := request.GetArguments()["fileUrl"].(string)
cleanup, _ := request.GetArguments()["cleanup"].(bool)
clearBefore, _ := request.GetArguments()["clearBefore"].(bool)
// Check if we have either path or URL
if (!hasPath || filePath == "") && (!hasUrl || fileUrl == "") {
return nil, fmt.Errorf("either filePath or fileUrl is required")
}
// If we have a URL, download it
downloadedFile := false
fileType := "image" // Default file type
if hasUrl && fileUrl != "" {
log.Info().Str("fileUrl", fileUrl).Msg("Downloading media file from URL")
downloadedPath, err := DownloadFileByUrl(fileUrl)
if err != nil {
return nil, fmt.Errorf("failed to download media file from URL: %v", err)
}
// Detect file type and rename with proper extension
renamedPath, err := DetectAndRenameMediaFile(downloadedPath)
if err != nil {
log.Warn().Err(err).Str("path", downloadedPath).Msg("Failed to detect file type or rename file, using original file")
filePath = downloadedPath
} else {
filePath = renamedPath
// Determine if it's a video based on extension
ext := strings.ToLower(filepath.Ext(renamedPath))
if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".wmv" || ext == ".flv" || ext == ".webm" || ext == ".mkv" {
fileType = "video"
}
}
downloadedFile = true
}
// Clear albums before pushing if requested
cleared := false
if clearBefore {
log.Info().Msg("Clearing albums before pushing new media file")
err := driverExt.IDriver.ClearImages()
if err != nil {
log.Warn().Err(err).Msg("Failed to clear albums before pushing, continuing anyway")
} else {
cleared = true
}
}
// Push the file to the device
err = driverExt.IDriver.PushImage(filePath)
if err != nil {
// If we downloaded the file and failed to push it, clean up
if downloadedFile && cleanup {
_ = os.Remove(filePath)
}
return nil, err
}
// Clean up downloaded file if requested
if downloadedFile && cleanup {
log.Info().Str("filePath", filePath).Msg("Cleaning up downloaded media file")
_ = os.Remove(filePath)
}
message := fmt.Sprintf("Successfully pushed %s to device", fileType)
returnData := ToolPushAlbums{
FilePath: filePath,
FileType: fileType,
Cleared: cleared,
}
// Include URL in response if it was used
if hasUrl && fileUrl != "" {
returnData.FileUrl = fileUrl
message = fmt.Sprintf("Successfully downloaded and pushed %s from %s to device", fileType, fileUrl)
}
// Add cleared info to message if applicable
if cleared {
message = fmt.Sprintf("%s (albums cleared before pushing)", message)
}
return NewMCPSuccessResponse(message, &returnData), nil
}
}
// Old ToolPushImage implementation has been removed as part of the refactoring to ToolPushAlbums
// ToolClearAlbums implements the clear_albums tool call.
type ToolClearAlbums struct {
// Return data fields - these define the structure of data returned by this tool
Cleared bool `json:"cleared" desc:"Whether albums were cleared successfully"`
}
func (t *ToolClearAlbums) Name() option.ActionName {
return option.ACTION_ClearAlbums
}
func (t *ToolClearAlbums) Description() string {
return "Clear media files (images and videos) from the device's gallery. For Android, this will clear media from the DCIM/Camera directory. For iOS, this will clear media from the device's photo album."
}
func (t *ToolClearAlbums) Options() []mcp.ToolOption {
return []mcp.ToolOption{
mcp.WithString("platform", mcp.Enum("android", "ios"), mcp.Description("The platform type of device to clear media from")),
mcp.WithString("serial", mcp.Description("The device serial number or UDID")),
}
}
func (t *ToolClearAlbums) Implement() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
driverExt, err := setupXTDriver(ctx, request.GetArguments())
if err != nil {
return nil, err
}
err = driverExt.IDriver.ClearImages()
if err != nil {
return nil, err
}
message := "Successfully cleared media files from device"
returnData := ToolClearAlbums{Cleared: true}
return NewMCPSuccessResponse(message, &returnData), nil
}
}
func (t *ToolClearAlbums) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
return BuildMCPCallToolRequest(t.Name(), map[string]any{}, action), nil
}

View File

@@ -2,9 +2,7 @@ package uixt
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/mark3labs/mcp-go/mcp"
@@ -15,7 +13,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 +53,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")),
}
}
@@ -47,38 +68,21 @@ func (t *ToolSleep) Implement() server.ToolHandlerFunc {
// Sleep action logic
log.Info().Interface("seconds", seconds).Msg("sleeping")
var duration time.Duration
var actualSeconds float64
switch v := seconds.(type) {
case float64:
actualSeconds = v
duration = time.Duration(v*1000) * time.Millisecond
case int:
actualSeconds = float64(v)
duration = time.Duration(v) * time.Second
case int64:
actualSeconds = float64(v)
duration = time.Duration(v) * time.Second
case string:
s, err := builtin.ConvertToFloat64(v)
if err != nil {
return nil, fmt.Errorf("invalid sleep duration: %v", v)
}
actualSeconds = s
duration = time.Duration(s*1000) * time.Millisecond
default:
return nil, fmt.Errorf("unsupported sleep duration type: %T", v)
// Use Interface2Float64 for unified type conversion
actualSeconds, err := builtin.Interface2Float64(seconds)
if err != nil {
return nil, fmt.Errorf("invalid sleep duration: %v", seconds)
}
duration := time.Duration(actualSeconds) * time.Second
// Extract start_time_ms and use sleepStrict for unified sleep logic
startTime, err := extractStartTimeMs(request)
if err != nil {
return nil, err
}
// 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
}
milliseconds := int64(actualSeconds * 1000)
sleepStrict(ctx, startTime, milliseconds)
message := fmt.Sprintf("Successfully slept for %v seconds", actualSeconds)
returnData := ToolSleep{
@@ -91,9 +95,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 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 {
// Use builtin.Interface2Float64 for unified parameter handling
var err error
seconds, err = builtin.Interface2Float64(action.Params)
if err != nil {
return mcp.CallToolRequest{}, fmt.Errorf("invalid sleep params: %v", action.Params)
}
arguments["seconds"] = seconds
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}
@@ -115,6 +134,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")),
}
}
@@ -129,38 +149,21 @@ func (t *ToolSleepMS) Implement() server.ToolHandlerFunc {
// Sleep MS action logic
log.Info().Interface("milliseconds", milliseconds).Msg("sleeping in milliseconds")
var duration time.Duration
var actualMilliseconds int64
switch v := milliseconds.(type) {
case float64:
actualMilliseconds = int64(v)
duration = time.Duration(v) * time.Millisecond
case int:
actualMilliseconds = int64(v)
duration = time.Duration(v) * time.Millisecond
case int64:
actualMilliseconds = v
duration = time.Duration(v) * time.Millisecond
case string:
ms, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid sleep duration: %v", v)
}
actualMilliseconds = ms
duration = time.Duration(ms) * time.Millisecond
default:
return nil, fmt.Errorf("unsupported sleep duration type: %T", v)
// Use Interface2Float64 for unified type conversion, then convert to int64
floatVal, err := builtin.Interface2Float64(milliseconds)
if err != nil {
return nil, fmt.Errorf("invalid sleep duration: %v", milliseconds)
}
actualMilliseconds := int64(floatVal)
duration := time.Duration(actualMilliseconds) * time.Millisecond
// Extract start_time_ms and use sleepStrict for unified sleep logic
startTime, err := extractStartTimeMs(request)
if err != nil {
return nil, err
}
// 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
}
sleepStrict(ctx, startTime, actualMilliseconds)
message := fmt.Sprintf("Successfully slept for %d milliseconds", actualMilliseconds)
returnData := ToolSleepMS{
@@ -173,17 +176,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()
} else if param, ok := action.Params.(int64); ok {
milliseconds = param
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,
// Use builtin.Interface2Float64 for unified parameter handling, then convert to int64
floatVal, err := builtin.Interface2Float64(action.Params)
if err != nil {
return mcp.CallToolRequest{}, fmt.Errorf("invalid sleep ms params: %v", action.Params)
}
milliseconds = int64(floatVal)
arguments["milliseconds"] = milliseconds
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}

View File

@@ -0,0 +1,285 @@
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: "float64 parameter",
action: option.MobileAction{
Method: option.ACTION_Sleep,
Params: float64(5.2),
},
expectedArgs: map[string]any{"seconds": float64(5.2)},
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,
},
{
name: "json.Number with integer value",
action: option.MobileAction{
Method: option.ACTION_Sleep,
Params: json.Number("10"),
},
expectedArgs: map[string]any{"seconds": float64(10)},
shouldError: false,
},
{
name: "json.Number with decimal value",
action: option.MobileAction{
Method: option.ACTION_Sleep,
Params: json.Number("1.25"),
},
expectedArgs: map[string]any{"seconds": float64(1.25)},
shouldError: false,
},
}
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: "float64 parameter",
action: option.MobileAction{
Method: option.ACTION_SleepMS,
Params: float64(2500.7),
},
expectedArgs: map[string]any{"milliseconds": int64(2500)},
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: "json.Number with decimal value",
action: option.MobileAction{
Method: option.ACTION_SleepMS,
Params: json.Number("1234.56"),
},
expectedArgs: map[string]any{"milliseconds": int64(1234)},
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

@@ -98,6 +98,10 @@ const (
ACTION_ListAvailableDevices ActionName = "list_available_devices"
ACTION_SelectDevice ActionName = "select_device"
// album actions (images and videos)
ACTION_PushAlbums ActionName = "push_albums"
ACTION_ClearAlbums ActionName = "clear_albums"
// custom actions
ACTION_SwipeToTapApp ActionName = "swipe_to_tap_app" // swipe left & right to find app and tap
ACTION_SwipeToTapText ActionName = "swipe_to_tap_text" // swipe up & down to find text and tap

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