mirror of
https://github.com/httprunner/httprunner.git
synced 2026-06-25 17:44:02 +08:00
Merge branch 'master' into session_refactor
This commit is contained in:
6
.github/workflows/claude-code.yml
vendored
6
.github/workflows/claude-code.yml
vendored
@@ -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]
|
||||
|
||||
|
||||
8
.github/workflows/smoketest.yml
vendored
8
.github/workflows/smoketest.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/unittest.yml
vendored
8
.github/workflows/unittest.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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
132
CLAUDE.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
171
examples/uitest/android_swipe_tap_loadmore.json
Normal file
171
examples/uitest/android_swipe_tap_loadmore.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
204
examples/uitest/ios_touch_simulator_test.go
Normal file
204
examples/uitest/ios_touch_simulator_test.go
Normal 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
|
||||
}
|
||||
206
examples/uitest/sph_search.json
Normal file
206
examples/uitest/sph_search.json
Normal 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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
64
examples/uitest/sph_search_test.go
Normal file
64
examples/uitest/sph_search_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -1 +1 @@
|
||||
v5.0.0-250814
|
||||
v5.0.0-250815
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
1
step.go
1
step.go
@@ -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
|
||||
|
||||
13
step_ui.go
13
step_ui.go
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ var (
|
||||
|
||||
// Ensure drivers implement SIMSupport interface
|
||||
_ SIMSupport = (*UIA2Driver)(nil)
|
||||
_ SIMSupport = (*WDADriver)(nil)
|
||||
)
|
||||
|
||||
// current implemeted driver: ADBDriver, UIA2Driver, WDADriver, HDCDriver
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
116
uixt/image_utils.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
285
uixt/mcp_tools_utility_test.go
Normal file
285
uixt/mcp_tools_utility_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user