refactor: move uixt pkg

This commit is contained in:
lilong.129
2025-03-05 11:03:28 +08:00
parent 9e064ee0ad
commit e107389d6e
122 changed files with 146 additions and 142 deletions

View File

@@ -1,84 +0,0 @@
# hrp convert
## 快速上手
```shell
$ hrp convert -h
convert multiple source format to HttpRunner JSON/YAML/gotest/pytest cases
Usage:
hrp convert $path... [flags]
Flags:
--from-har load from HAR format
--from-json load from json case format (default true)
--from-postman load from postman format
--from-yaml load from yaml case format
-h, --help help for convert
-d, --output-dir string specify output directory
-p, --profile string specify profile path to override headers and cookies
--to-json convert to JSON case scripts (default true)
--to-pytest convert to pytest scripts
--to-yaml convert to YAML case scripts
Global Flags:
--log-json set log to json format
-l, --log-level string set log level (default "INFO")
--venv string specify python3 venv path
```
`hrp convert` 指令用于将 HAR/Postman/JMeter/Swagger 文件或 curl/Apache ab 指令转化为 HttpRunner JSON/YAML/gotest/pytest 形态的测试用例,同时也支持 HttpRunner 测试用例各个形态之间的相互转化。
该指令所有选项的详细说明如下:
- `--to-json / --to-yaml / --to-gotest / --to-pytest` 用于将输入转化为对应形态的 HttpRunner 测试用例,四个选项中最多只能指定一个,如果不指定则默认会将输入转化为 JSON 形态的测试用例
- `--output-dir` 后接测试用例的期望输出目录的路径,用于将转换生成的测试用例输出到对应的文件夹;默认输出的文件夹为源文件所在的文件夹
- `--profile` 后接 profile 配置文件的路径,目前支持替换(不存在则会创建)或者覆盖输入的外部脚本/测试用例中的 `Headers``Cookies` 信息profile 文件的后缀可以为 `json/yaml/yml`,下面给出两类 profile 配置文件的示例:
- 根据 profile 替换指定的 `Headers``Cookies` 信息
```yaml
headers:
Header1: "this header will be created or updated"
cookies:
Cookie1: "this cookie will be created or updated"
```
- 根据 profile 覆盖原有的 `Headers``Cookies` 信息
```yaml
override: true
headers:
Header1: "all original headers will be overridden"
cookies:
Cookie1: "all original cookies will be overridden"
```
## 注意事项
1. 输出的测试用例文件名格式为 `源文件名称(不带拓展名)` + `_test` + `.json/.yaml/.go/.py 后缀`,如果该文件已经存在则会进行覆盖
2. 在 profile 文件中,指定 `override` 字段为 `false/true` 可以选择修改模式为替换/覆盖。需要注意的是,如果不指定该字段则 profile 的默认修改模式为替换模式
3. 输入为 JSON/YAML 测试用例时,良好兼容 Golang/Python 双引擎的请求体、断言格式细微差异,输出的 JSON/YAML 则统一采用 Golang 引擎的风格
## 转换流程图
`hrp convert` 的转换过程流程图如下:
![flow chart](asset/flowgram.png)
## 开发进度
`hrp convert` 当前的开发进度如下:
| from \ to | JSON | YAML | GoTest | PyTest |
|:---------:|:----:|:----:|:------:|:------:|
| HAR | ✅ | ✅ | ❌ | ✅ |
| Postman | ✅ | ✅ | ❌ | ✅ |
| JMeter | ❌ | ❌ | ❌ | ❌ |
| Swagger | ❌ | ❌ | ❌ | ❌ |
| curl | ✅ | ✅ | ❌ | ✅ |
| Apache ab | ❌ | ❌ | ❌ | ❌ |
| JSON | ✅ | ✅ | ❌ | ✅ |
| YAML | ✅ | ✅ | ❌ | ✅ |
| GoTest | ❌ | ❌ | ❌ | ❌ |
| PyTest | ❌ | ❌ | ❌ | ❌ |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1 +0,0 @@
package convert

View File

@@ -1,507 +0,0 @@
package convert
import (
"bufio"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/google/shlex"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/internal/json"
)
const (
originCmdKey = "_origin_cmd_key"
targetUrlKey = "_target_url_key"
)
var curlOptionAliasMap = map[string]string{
"-a": "--append",
"-A": "--user-agent",
"-b": "--cookie",
"-B": "--use-ascii",
"-c": "--cookie-jar",
"-C": "--continue-at",
"-d": "--data",
"-D": "--dump-header",
"-e": "--referer",
"-E": "--cert",
"-f": "--fail",
"-F": "--form",
"-g": "--globoff",
"-G": "--get",
"-h": "--help",
"-H": "--header",
"-i": "--include",
"-I": "--head",
"-j": "--junk-session-cookies",
"-J": "--remote-header-name",
"-k": "--insecure",
"-K": "--config",
"-l": "--list-only",
"-L": "--location",
"-m": "--max-time",
"-M": "--manual",
"-n": "--netrc",
"-N": "--no-buffer",
"-o": "--output",
"-O": "--remote-name",
"-p": "--proxytunnel",
"-P": "--ftp-port",
"-q": "--disable",
"-Q": "--quote",
"-r": "--range",
"-R": "--remote-time",
"-s": "--silent",
"-S": "--show-error",
"-t": "--telnet-option",
"-T": "--upload-file",
"-u": "--user",
"-U": "--proxy-user",
"-v": "--verbose",
"-V": "--version",
"-w": "--write-out",
"-x": "--proxy",
"-X": "--request",
"-Y": "--speed-limit",
"-y": "--speed-time",
"-z": "--time-cond",
"-Z": "--parallel",
}
var curlOptionWhiteMap = map[string]struct{}{
"--cookie": {},
"--data": {},
"--form": {},
"--get": {},
"--head": {},
"--header": {},
"--request": {},
}
var curlOptionWhiteList []string
func init() {
for option := range curlOptionWhiteMap {
curlOptionWhiteList = append(curlOptionWhiteList, option)
}
}
// LoadCurlCase loads testcase from one or more curl commands in .txt file
func LoadCurlCase(path string) (*hrp.TestCaseDef, error) {
cmds, err := readFileLines(path)
if err != nil {
return nil, err
}
tCase := &hrp.TestCaseDef{
Config: &hrp.TConfig{
Name: "testcase converted from curl command",
},
}
for _, cmd := range cmds {
tSteps, err := LoadCurlSteps(cmd)
if err != nil {
return nil, err
}
tCase.Steps = append(tCase.Steps, tSteps...)
}
err = hrp.ConvertCaseCompatibility(tCase)
if err != nil {
return nil, err
}
return tCase, nil
}
func readFileLines(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
log.Error().Err(err).Str("path", path).Msg("open file failed")
return nil, err
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line == "\n" {
continue
}
lines = append(lines, line)
}
return lines, scanner.Err()
}
// LoadCurlSteps loads one teststep from one curl command
func LoadCurlSteps(cmd string) ([]*hrp.TStep, error) {
caseCurl, err := loadCaseCurl(cmd)
if err != nil {
return nil, err
}
return caseCurl.toTSteps()
}
func loadCaseCurl(cmd string) (CaseCurl, error) {
caseCurl := make(CaseCurl)
var err error
caseCurl, err = parseCaseCurl(cmd)
if err != nil {
return nil, errors.Wrap(err, "load curl command failed")
}
// deal with option alias, turn all options to long form
if err = caseCurl.toAlias(); err != nil {
return nil, errors.Wrap(err, "identify curl option alias failed")
}
// check if caseCurl contains unsupported args
if err = caseCurl.checkOptions(); err != nil {
return nil, errors.Wrap(err, "check curl option failed")
}
caseCurl.Set(originCmdKey, cmd)
return caseCurl, nil
}
// parseCaseCurl parses command string to map, save command keyword and bool option as map key only.
// Otherwise, save option as map key and the following args([]string) as map value
func parseCaseCurl(cmd string) (CaseCurl, error) {
cmdWords, err := shlex.Split(cmd)
if err != nil {
return nil, err
}
// parse the command string to map
res := make(CaseCurl)
var i int
if cmdWords[i] != "curl" {
return nil, errors.New("command not started with curl")
}
i++
for i < len(cmdWords) {
if !strings.HasPrefix(cmdWords[i], "-") {
// save target url
res.Add(targetUrlKey, cmdWords[i])
i++
continue
}
option := cmdWords[i]
i++
if i < len(cmdWords) && !strings.HasPrefix(cmdWords[i], "-") {
// option with only one following argument
res.Add(option, cmdWords[i])
i++
continue
}
// option with no argument, i.e. bool option, save key only
res[option] = nil
}
return res, nil
}
type CaseCurl map[string][]string
// Get gets the first value associated with the given key.
// If there are no values associated with the key, Get returns the empty string.
func (c CaseCurl) Get(key string, index int) string {
if c == nil {
return ""
}
vs := c[key]
if index >= 0 && index < len(vs) {
return vs[index]
}
return ""
}
func (c CaseCurl) Set(key, value string) {
c[key] = []string{value}
}
func (c CaseCurl) Add(key, value string) {
c[key] = append(c[key], value)
}
// HaveKey checks key existed or not
func (c CaseCurl) HaveKey(key string) bool {
if c == nil {
return false
}
_, ok := c[key]
return ok
}
func (c CaseCurl) toAlias() error {
for option, args := range c {
if !strings.HasPrefix(option, "-") || strings.HasPrefix(option, "--") {
// not a short option like -X, pass
continue
}
longOption, ok := curlOptionAliasMap[option]
if !ok {
return errors.Errorf("unexpected curl option: %v", option)
}
// FIXME: need to copy args or not?
c[longOption] = args
delete(c, option)
}
return nil
}
func (c CaseCurl) checkOptions() error {
for option := range c {
if option == originCmdKey || option == targetUrlKey {
continue
}
_, ok := curlOptionWhiteMap[option]
if !ok {
return errors.Errorf("option %v not supported yet. available options: %v", option, curlOptionWhiteList)
}
}
return nil
}
func (c CaseCurl) toTSteps() ([]*hrp.TStep, error) {
var tSteps []*hrp.TStep
for _, rawUrl := range c[targetUrlKey] {
log.Info().
Str("url", rawUrl).
Msg("convert test steps")
step := &stepFromCurl{
TStep: &hrp.TStep{
Request: &hrp.Request{},
},
}
if err := step.makeRequestName(c); err != nil {
return nil, err
}
if err := step.makeRequestMethod(c); err != nil {
return nil, err
}
if err := step.makeRequestURL(rawUrl); err != nil {
return nil, err
}
if err := step.makeRequestParams(rawUrl); err != nil {
return nil, err
}
if err := step.makeRequestHeaders(c); err != nil {
return nil, err
}
if err := step.makeRequestCookies(c); err != nil {
return nil, err
}
if err := step.makeRequestBody(c); err != nil {
return nil, err
}
tSteps = append(tSteps, step.TStep)
}
return tSteps, nil
}
type stepFromCurl struct {
*hrp.TStep
}
func (s *stepFromCurl) makeRequestName(c CaseCurl) error {
s.StepName = c.Get(originCmdKey, 0)
return nil
}
func (s *stepFromCurl) makeRequestMethod(c CaseCurl) error {
// default --get
s.Request.Method = http.MethodGet
if c.HaveKey("--data") || c.HaveKey("--form") {
s.Request.Method = http.MethodPost
}
if c.HaveKey("--head") {
s.Request.Method = http.MethodHead
}
if c.HaveKey("--request") {
s.Request.Method = hrp.HTTPMethod(strings.ToUpper(c.Get("--request", 0)))
}
return nil
}
func (s *stepFromCurl) makeRequestURL(rawUrl string) error {
u, err := url.Parse(rawUrl)
if err != nil {
return errors.Wrap(err, "parse URL error")
}
// default protocol consistent with curl (http)
if u.Scheme == "" {
u.Scheme = "http"
}
s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path)
return nil
}
func (s *stepFromCurl) makeRequestParams(rawUrl string) error {
s.Request.Params = make(map[string]interface{})
u, err := url.Parse(rawUrl)
if err != nil {
return errors.Wrap(err, "parse URL error")
}
s.Request.Params = make(map[string]interface{})
queryValues := u.Query()
// query key may correspond to more than one value, get first query key only
for k := range queryValues {
s.Request.Params[k] = queryValues.Get(k)
}
return nil
}
func (s *stepFromCurl) makeRequestHeaders(c CaseCurl) error {
s.Request.Headers = make(map[string]string)
headerList := c["--header"]
for _, headerExpr := range headerList {
if err := s.makeRequestHeader(headerExpr); err != nil {
return err
}
}
return nil
}
func (s *stepFromCurl) makeRequestHeader(headerExpr string) error {
headerExpr = strings.TrimSpace(headerExpr)
if strings.HasPrefix(headerExpr, "@") {
return errors.Errorf("loading header from file not supported: %v", headerExpr)
}
if strings.TrimSpace(headerExpr) == ";" || strings.HasPrefix(strings.TrimSpace(headerExpr), ":") {
return errors.Errorf("invalid curl header format: %v", headerExpr)
}
if s.Request.Headers == nil {
s.Request.Headers = make(map[string]string)
}
if i := strings.Index(headerExpr, ":"); i != -1 {
headerKey := strings.TrimSpace(headerExpr[:i])
var headerValue string
if i < len(headerExpr)-1 {
headerValue = strings.TrimSpace(headerExpr[i+1:])
}
if strings.ToLower(headerKey) == "host" {
// headerExpr modifying internal header like "Host:"
log.Warn().Str("--header", headerExpr).Msg("modifying internal header not supported")
return nil
}
if headerValue != "" {
// normal headerExpr like "User-Agent: httprunner"
s.Request.Headers[headerKey] = headerValue
return nil
}
}
if i := strings.Index(headerExpr, ";"); i != -1 {
// headerExpr terminated with a semicolon like "X-Custom-Header;"
headerKey := strings.TrimSpace(headerExpr[:i])
if strings.ToLower(headerKey) == "host" {
log.Warn().Str("--header", headerExpr).Msg("modifying internal header not supported")
return nil
}
s.Request.Headers[headerKey] = ""
return nil
}
log.Warn().Str("--header", headerExpr).Msg("pass meaningless curl header expression")
return nil
}
func (s *stepFromCurl) makeRequestCookies(c CaseCurl) error {
s.Request.Cookies = make(map[string]string)
cookieList := c["--cookie"]
for _, cookieExpr := range cookieList {
if err := s.makeRequestCookie(cookieExpr); err != nil {
return err
}
}
return nil
}
func (s *stepFromCurl) makeRequestCookie(cookieExpr string) error {
if !strings.Contains(cookieExpr, "=") {
return errors.Errorf("loading cookie from file not supported: %v", cookieExpr)
}
if s.Request.Cookies == nil {
s.Request.Cookies = make(map[string]string)
}
// deal with cookieExpr like "name1=value1; name2 = value2"
cookies := strings.Split(cookieExpr, ";")
for _, cookie := range cookies {
i := strings.Index(cookie, "=")
if i == -1 {
log.Warn().Str("--cookie", cookie).Msg("pass meaningless curl cookie expression")
continue
}
cookieKey := strings.TrimSpace(cookie[:i])
var cookieValue string
if i < len(cookie)-1 {
cookieValue = strings.TrimSpace(cookie[i+1:])
}
s.Request.Cookies[cookieKey] = cookieValue
}
return nil
}
func (s *stepFromCurl) makeRequestBody(c CaseCurl) error {
// check priority: --data > --form
dataList, dataExisted := c["--data"]
formList, formExisted := c["--form"]
if dataExisted {
if err := s.makeRequestData(dataList); err != nil {
return err
}
} else if formExisted {
if err := s.makeRequestForm(formList); err != nil {
return err
}
}
return nil
}
func (s *stepFromCurl) makeRequestData(dataList []string) error {
dataMap := make(map[string]interface{})
for _, dataExpr := range dataList {
if strings.HasPrefix(dataExpr, "@") {
return errors.Errorf("loading data from file not supported: %v", dataExpr)
}
var m map[string]interface{}
// --data may be json string, try to unmarshal to map first
err := json.Unmarshal([]byte(dataExpr), &m)
if err == nil {
for k, v := range m {
dataMap[k] = v
}
continue
}
dataValues, err := url.ParseQuery(dataExpr)
if err != nil {
return err
}
for dataKey := range dataValues {
dataMap[dataKey] = strings.Trim(dataValues.Get(dataKey), "\"'")
}
}
s.Request.Body = dataMap
return nil
}
func (s *stepFromCurl) makeRequestForm(formList []string) error {
if s.Request.Upload == nil {
s.Request.Upload = make(map[string]interface{})
}
for _, formExpr := range formList {
if !strings.Contains(formExpr, "=") {
return errors.Errorf("option --form: is badly used: %v", formExpr)
}
if i := strings.Index(formExpr, "="); i != -1 {
formKey := strings.TrimSpace(formExpr[:i])
var formValue string
if i < len(formExpr)-1 {
formValue = strings.TrimSpace(formExpr[i+1:])
}
s.Request.Upload[formKey] = strings.Trim(formValue, "\"")
}
}
return nil
}

View File

@@ -1,104 +0,0 @@
package convert
import (
"testing"
"github.com/stretchr/testify/assert"
)
var curlPath = "../../../examples/data/curl/curl_examples.txt"
func TestLoadCurlCase(t *testing.T) {
tCase, err := LoadCurlCase(curlPath)
if !assert.NoError(t, err) {
t.Fatal(err)
}
if !assert.Equal(t, 6, len(tCase.Steps)) {
t.Fatal()
}
// curl httpbin.org
if !assert.Equal(t, "curl httpbin.org", tCase.Steps[0].StepName) {
t.Fatal()
}
if !assert.EqualValues(t, "GET", tCase.Steps[0].Request.Method) {
t.Fatal()
}
if !assert.Equal(t, "http://httpbin.org", tCase.Steps[0].Request.URL) {
t.Fatal()
}
// curl https://httpbin.org/get?key1=value1&key2=value2
if !assert.Equal(t, "https://httpbin.org/get", tCase.Steps[1].Request.URL) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{
"key1": "value1",
"key2": "value2",
}, tCase.Steps[1].Request.Params) {
t.Fatal()
}
// curl -H "Content-Type: application/json" \
// -H "Authorization: Bearer b7d03a6947b217efb6f3ec3bd3504582" \
// -d '{"type":"A","name":"www","data":"162.10.66.0","priority":null,"port":null,"weight":null}' \
// "https://httpbin.org/post"
if !assert.EqualValues(t, "POST", tCase.Steps[2].Request.Method) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"Authorization": "Bearer b7d03a6947b217efb6f3ec3bd3504582",
"Content-Type": "application/json",
}, tCase.Steps[2].Request.Headers) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{
"data": "162.10.66.0",
"name": "www",
"port": nil,
"priority": nil,
"type": "A",
"weight": nil,
}, tCase.Steps[2].Request.Body) {
t.Fatal()
}
// curl -F "dummyName=dummyFile" -F file1=@file1.txt -F file2=@file2.txt https://httpbin.org/post
if !assert.Equal(t, map[string]interface{}{
"dummyName": "dummyFile",
"file1": "@file1.txt",
"file2": "@file2.txt",
}, tCase.Steps[3].Request.Upload) {
t.Fatal()
}
// curl https://httpbin.org/post \
// -d 'shipment[to_address][id]=adr_HrBKVA85' \
// -d 'shipment[from_address][id]=adr_VtuTOj7o' \
// -d 'shipment[parcel][id]=prcl_WDv2VzHp' \
// -d 'shipment[is_return]=true' \
// -d 'shipment[customs_info][id]=cstinfo_bl5sE20Y'
if !assert.Equal(t, map[string]interface{}{
"shipment[customs_info][id]": "cstinfo_bl5sE20Y",
"shipment[from_address][id]": "adr_VtuTOj7o",
"shipment[is_return]": "true",
"shipment[parcel][id]": "prcl_WDv2VzHp",
"shipment[to_address][id]": "adr_HrBKVA85",
}, tCase.Steps[4].Request.Body) {
t.Fatal()
}
// curl https://httpbing.org/post -H "Content-Type: application/x-www-form-urlencoded" \
// --data "key1=value+1&key2=value%3A2"
if !assert.Equal(t, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
}, tCase.Steps[5].Request.Headers) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{
"key1": "value 1",
"key2": "value:2",
}, tCase.Steps[5].Request.Body) {
t.Fatal()
}
}

View File

@@ -1,19 +0,0 @@
package convert
import (
_ "embed"
"os"
"github.com/rs/zerolog/log"
)
func convert2GoTestScripts(paths ...string) error {
log.Warn().Msg("convert to gotest scripts is not supported yet")
os.Exit(1)
// format pytest scripts with black
return nil
}
//go:embed testcase.tmpl
var testcaseTemplate string

View File

@@ -1,624 +0,0 @@
package convert
import (
"encoding/base64"
"fmt"
"net/url"
"reflect"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/internal/json"
)
// ==================== model definition starts here ====================
/*
HTTP Archive (HAR) format
https://w3c.github.io/web-performance/specs/HAR/Overview.html
this file is copied from https://github.com/mrichman/hargo/blob/master/types.go
*/
// CaseHar is a container type for deserialization
type CaseHar struct {
Log Log `json:"log"`
}
// Log represents the root of the exported data. This object MUST be present and its name MUST be "log".
type Log struct {
// The object contains the following name/value pairs:
// Required. Version number of the format.
Version string `json:"version"`
// Required. An object of type creator that contains the name and version
// information of the log creator application.
Creator Creator `json:"creator"`
// Optional. An object of type browser that contains the name and version
// information of the user agent.
Browser Browser `json:"browser"`
// Optional. An array of objects of type page, each representing one exported
// (tracked) page. Leave out this field if the application does not support
// grouping by pages.
Pages []Page `json:"pages,omitempty"`
// Required. An array of objects of type entry, each representing one
// exported (tracked) HTTP request.
Entries []Entry `json:"entries"`
// Optional. A comment provided by the user or the application. Sorting
// entries by startedDateTime (starting from the oldest) is preferred way how
// to export data since it can make importing faster. However the reader
// application should always make sure the array is sorted (if required for
// the import).
Comment string `json:"comment"`
}
// Creator contains information about the log creator application
type Creator struct {
// Required. The name of the application that created the log.
Name string `json:"name"`
// Required. The version number of the application that created the log.
Version string `json:"version"`
// Optional. A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Browser that created the log
type Browser struct {
// Required. The name of the browser that created the log.
Name string `json:"name"`
// Required. The version number of the browser that created the log.
Version string `json:"version"`
// Optional. A comment provided by the user or the browser.
Comment string `json:"comment"`
}
// Page object for every exported web page and one <entry> object for every HTTP request.
// In case when an HTTP trace tool isn't able to group requests by a page,
// the <pages> object is empty and individual requests doesn't have a parent page.
type Page struct {
/* There is one <page> object for every exported web page and one <entry>
object for every HTTP request. In case when an HTTP trace tool isn't able to
group requests by a page, the <pages> object is empty and individual
requests doesn't have a parent page.
*/
// Date and time stamp for the beginning of the page load
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.45+01:00).
StartedDateTime string `json:"startedDateTime"`
// Unique identifier of a page within the . Entries use it to refer the parent page.
ID string `json:"id"`
// Page title.
Title string `json:"title"`
// Detailed timing info about page load.
PageTiming PageTiming `json:"pageTiming"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// PageTiming describes timings for various events (states) fired during the page load.
// All times are specified in milliseconds. If a time info is not available appropriate field is set to -1.
type PageTiming struct {
// Content of the page loaded. Number of milliseconds since page load started
// (page.startedDateTime). Use -1 if the timing does not apply to the current
// request.
// Depeding on the browser, onContentLoad property represents DOMContentLoad
// event or document.readyState == interactive.
OnContentLoad int `json:"onContentLoad"`
// Page is loaded (onLoad event fired). Number of milliseconds since page
// load started (page.startedDateTime). Use -1 if the timing does not apply
// to the current request.
OnLoad int `json:"onLoad"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment"`
}
// Entry is a unique, optional Reference to the parent page.
// Leave out this field if the application does not support grouping by pages.
type Entry struct {
Pageref string `json:"pageref,omitempty"`
// Date and time stamp of the request start
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD).
StartedDateTime string `json:"startedDateTime"`
// Total elapsed time of the request in milliseconds. This is the sum of all
// timings available in the timings object (i.e. not including -1 values) .
Time float32 `json:"time"`
// Detailed info about the request.
Request Request `json:"request"`
// Detailed info about the response.
Response Response `json:"response"`
// Info about cache usage.
Cache Cache `json:"cache"`
// Detailed timing info about request/response round trip.
PageTimings PageTimings `json:"pageTimings"`
// optional (new in 1.2) IP address of the server that was connected
// (result of DNS resolution).
ServerIPAddress string `json:"serverIPAddress,omitempty"`
// optional (new in 1.2) Unique ID of the parent TCP/IP connection, can be
// the client port number. Note that a port number doesn't have to be unique
// identifier in cases where the port is shared for more connections. If the
// port isn't available for the application, any other unique connection ID
// can be used instead (e.g. connection index). Leave out this field if the
// application doesn't support this info.
Connection string `json:"connection,omitempty"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Request contains detailed info about performed request.
type Request struct {
// Request method (GET, POST, ...).
Method string `json:"method"`
// Absolute URL of the request (fragments are not included).
URL string `json:"url"`
// Request HTTP Version.
HTTPVersion string `json:"httpVersion"`
// List of cookie objects.
Cookies []Cookie `json:"cookies"`
// List of header objects.
Headers []NVP `json:"headers"`
// List of query parameter objects.
QueryString []NVP `json:"queryString"`
// Posted data.
PostData PostData `json:"postData"`
// Total number of bytes from the start of the HTTP request message until
// (and including) the double CRLF before the body. Set to -1 if the info
// is not available.
HeaderSize int `json:"headerSize"`
// Size of the request body (POST data payload) in bytes. Set to -1 if the
// info is not available.
BodySize int `json:"bodySize"`
// (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment"`
}
// Response contains detailed info about the response.
type Response struct {
// Response status.
Status int `json:"status"`
// Response status description.
StatusText string `json:"statusText"`
// Response HTTP Version.
HTTPVersion string `json:"httpVersion"`
// List of cookie objects.
Cookies []Cookie `json:"cookies"`
// List of header objects.
Headers []NVP `json:"headers"`
// Details about the response body.
Content Content `json:"content"`
// Redirection target URL from the Location response header.
RedirectURL string `json:"redirectURL"`
// Total number of bytes from the start of the HTTP response message until
// (and including) the double CRLF before the body. Set to -1 if the info is
// not available.
// The size of received response-headers is computed only from headers that
// are really received from the server. Additional headers appended by the
// browser are not included in this number, but they appear in the list of
// header objects.
HeadersSize int `json:"headersSize"`
// Size of the received response body in bytes. Set to zero in case of
// responses coming from the cache (304). Set to -1 if the info is not
// available.
BodySize int `json:"bodySize"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Cookie contains list of all cookies (used in <request> and <response> objects).
type Cookie struct {
// The name of the cookie.
Name string `json:"name"`
// The cookie value.
Value string `json:"value"`
// optional The path pertaining to the cookie.
Path string `json:"path,omitempty"`
// optional The host of the cookie.
Domain string `json:"domain,omitempty"`
// optional Cookie expiration time.
// (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.123+02:00).
Expires string `json:"expires,omitempty"`
// optional Set to true if the cookie is HTTP only, false otherwise.
HTTPOnly bool `json:"httpOnly,omitempty"`
// optional (new in 1.2) True if the cookie was transmitted over ssl, false
// otherwise.
Secure bool `json:"secure,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment bool `json:"comment,omitempty"`
}
// NVP is simply a name/value pair with a comment
type NVP struct {
Name string `json:"name"`
Value string `json:"value"`
Comment string `json:"comment,omitempty"`
}
// PostData describes posted data, if any (embedded in <request> object).
type PostData struct {
// Mime type of posted data.
MimeType string `json:"mimeType"`
// List of posted parameters (in case of URL encoded parameters).
Params []PostParam `json:"params"`
// Plain text posted data
Text string `json:"text"`
// optional (new in 1.2) A comment provided by the user or the
// application.
Comment string `json:"comment,omitempty"`
}
// PostParam is a list of posted parameters, if any (embedded in <postData> object).
type PostParam struct {
// name of a posted parameter.
Name string `json:"name"`
// optional value of a posted parameter or content of a posted file.
Value string `json:"value,omitempty"`
// optional name of a posted file.
FileName string `json:"fileName,omitempty"`
// optional content type of a posted file.
ContentType string `json:"contentType,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Content describes details about response content (embedded in <response> object).
type Content struct {
// Length of the returned content in bytes. Should be equal to
// response.bodySize if there is no compression and bigger when the content
// has been compressed.
Size int `json:"size"`
// optional Number of bytes saved. Leave out this field if the information
// is not available.
Compression int `json:"compression,omitempty"`
// MIME type of the response text (value of the Content-Type response
// header). The charset attribute of the MIME type is included (if
// available).
MimeType string `json:"mimeType"`
// optional Response body sent from the server or loaded from the browser
// cache. This field is populated with textual content only. The text field
// is either HTTP decoded text or a encoded (e.g. "base64") representation of
// the response body. Leave out this field if the information is not
// available.
Text string `json:"text,omitempty"`
// optional (new in 1.2) Encoding used for response text field e.g
// "base64". Leave out this field if the text field is HTTP decoded
// (decompressed & unchunked), than trans-coded from its original character
// set into UTF-8.
Encoding string `json:"encoding,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// Cache contains info about a request coming from browser cache.
type Cache struct {
// optional State of a cache entry before the request. Leave out this field
// if the information is not available.
BeforeRequest CacheObject `json:"beforeRequest,omitempty"`
// optional State of a cache entry after the request. Leave out this field if
// the information is not available.
AfterRequest CacheObject `json:"afterRequest,omitempty"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// CacheObject is used by both beforeRequest and afterRequest
type CacheObject struct {
// optional - Expiration time of the cache entry.
Expires string `json:"expires,omitempty"`
// The last time the cache entry was opened.
LastAccess string `json:"lastAccess"`
// Etag
ETag string `json:"eTag"`
// The number of times the cache entry has been opened.
HitCount int `json:"hitCount"`
// optional (new in 1.2) A comment provided by the user or the application.
Comment string `json:"comment,omitempty"`
}
// PageTimings describes various phases within request-response round trip.
// All times are specified in milliseconds.
type PageTimings struct {
Blocked int `json:"blocked,omitempty"`
// optional - Time spent in a queue waiting for a network connection. Use -1
// if the timing does not apply to the current request.
DNS int `json:"dns,omitempty"`
// optional - DNS resolution time. The time required to resolve a host name.
// Use -1 if the timing does not apply to the current request.
Connect int `json:"connect,omitempty"`
// optional - Time required to create TCP connection. Use -1 if the timing
// does not apply to the current request.
Send int `json:"send"`
// Time required to send HTTP request to the server.
Wait int `json:"wait"`
// Waiting for a response from the server.
Receive int `json:"receive"`
// Time required to read entire response from the server (or cache).
Ssl int `json:"ssl,omitempty"`
// optional (new in 1.2) - Time required for SSL/TLS negotiation. If this
// field is defined then the time is also included in the connect field (to
// ensure backward compatibility with HAR 1.1). Use -1 if the timing does not
// apply to the current request.
Comment string `json:"comment,omitempty"`
// optional (new in 1.2) - A comment provided by the user or the application.
}
// TestResult contains results for an individual HTTP request
type TestResult struct {
URL string `json:"url"`
Status int `json:"status"` // 200, 500, etc.
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Latency int `json:"latency"` // milliseconds
Method string `json:"method"`
HarFile string `json:"harfile"`
}
// ==================== model definition ends here ====================
func LoadHARCase(path string) (*hrp.TestCaseDef, error) {
// load har file
caseHAR, err := loadCaseHAR(path)
if err != nil {
return nil, err
}
// convert to TestCase format
return caseHAR.ToTestCase()
}
func loadCaseHAR(path string) (*CaseHar, error) {
caseHAR := new(CaseHar)
err := hrp.LoadFileObject(path, caseHAR)
if err != nil {
return nil, errors.Wrap(err, "load har file failed")
}
if reflect.ValueOf(*caseHAR).IsZero() {
return nil, errors.New("invalid har file")
}
return caseHAR, nil
}
// convert CaseHar to TestCase format
func (c *CaseHar) ToTestCase() (*hrp.TestCaseDef, error) {
teststeps, err := c.prepareTestSteps()
if err != nil {
return nil, err
}
tCase := &hrp.TestCaseDef{
Config: c.prepareConfig(),
Steps: teststeps,
}
err = hrp.ConvertCaseCompatibility(tCase)
if err != nil {
return nil, err
}
return tCase, nil
}
func (c *CaseHar) prepareConfig() *hrp.TConfig {
return hrp.NewConfig("testcase description").
SetVerifySSL(false)
}
func (c *CaseHar) prepareTestSteps() ([]*hrp.TStep, error) {
var steps []*hrp.TStep
for _, entry := range c.Log.Entries {
step, err := c.prepareTestStep(&entry)
if err != nil {
return nil, err
}
steps = append(steps, step)
}
return steps, nil
}
func (c *CaseHar) prepareTestStep(entry *Entry) (*hrp.TStep, error) {
log.Info().
Str("method", entry.Request.Method).
Str("url", entry.Request.URL).
Msg("convert teststep")
step := &stepFromHAR{
TStep: hrp.TStep{
Request: &hrp.Request{},
StepConfig: hrp.StepConfig{
Validators: make([]interface{}, 0),
},
},
}
if err := step.makeRequestMethod(entry); err != nil {
return nil, err
}
if err := step.makeRequestURL(entry); err != nil {
return nil, err
}
if err := step.makeRequestParams(entry); err != nil {
return nil, err
}
if err := step.makeRequestCookies(entry); err != nil {
return nil, err
}
if err := step.makeRequestHeaders(entry); err != nil {
return nil, err
}
if err := step.makeRequestBody(entry); err != nil {
return nil, err
}
if err := step.makeValidate(entry); err != nil {
return nil, err
}
return &step.TStep, nil
}
type stepFromHAR struct {
hrp.TStep
}
func (s *stepFromHAR) makeRequestMethod(entry *Entry) error {
s.Request.Method = hrp.HTTPMethod(entry.Request.Method)
return nil
}
func (s *stepFromHAR) makeRequestURL(entry *Entry) error {
u, err := url.Parse(entry.Request.URL)
if err != nil {
log.Error().Err(err).Msg("make request url failed")
return err
}
s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path)
return nil
}
func (s *stepFromHAR) makeRequestParams(entry *Entry) error {
s.Request.Params = make(map[string]interface{})
for _, param := range entry.Request.QueryString {
s.Request.Params[param.Name] = param.Value
}
return nil
}
func (s *stepFromHAR) makeRequestCookies(entry *Entry) error {
// use cookies from har
s.Request.Cookies = make(map[string]string)
for _, cookie := range entry.Request.Cookies {
s.Request.Cookies[cookie.Name] = cookie.Value
}
return nil
}
func (s *stepFromHAR) makeRequestHeaders(entry *Entry) error {
// use headers from har
s.Request.Headers = make(map[string]string)
for _, header := range entry.Request.Headers {
if strings.EqualFold(header.Name, "cookie") {
continue
}
s.Request.Headers[header.Name] = header.Value
}
return nil
}
func (s *stepFromHAR) makeRequestBody(entry *Entry) error {
mimeType := entry.Request.PostData.MimeType
if mimeType == "" {
// GET/HEAD/DELETE without body
return nil
}
// POST/PUT with body
if strings.HasPrefix(mimeType, "application/json") {
// post json
var body interface{}
if entry.Request.PostData.Text == "" {
body = nil
} else {
err := json.Unmarshal([]byte(entry.Request.PostData.Text), &body)
if err != nil {
log.Error().Err(err).Msg("make request body failed")
return err
}
}
s.Request.Body = body
} else if strings.HasPrefix(mimeType, "application/x-www-form-urlencoded") {
// post form
paramsMap := make(map[string]string)
for _, param := range entry.Request.PostData.Params {
paramsMap[param.Name] = param.Value
}
s.Request.Body = paramsMap
} else if strings.HasPrefix(mimeType, "text/plain") {
// post raw data
s.Request.Body = entry.Request.PostData.Text
} else {
// TODO
log.Error().Msgf("makeRequestBody: Not implemented for mimeType %s", mimeType)
}
return nil
}
func (s *stepFromHAR) makeValidate(entry *Entry) error {
// make validator for response status code
s.Validators = append(s.Validators, hrp.Validator{
Check: "status_code",
Assert: "equals",
Expect: entry.Response.Status,
Message: "assert response status code",
})
// make validators for response headers
for _, header := range entry.Response.Headers {
// assert Content-Type
if strings.EqualFold(header.Name, "Content-Type") {
s.Validators = append(s.Validators, hrp.Validator{
Check: "headers.\"Content-Type\"",
Assert: "equals",
Expect: header.Value,
Message: "assert response header Content-Type",
})
}
}
// make validators for response body
respBody := entry.Response.Content
if respBody.Text == "" {
// response body is empty
return nil
}
if strings.HasPrefix(respBody.MimeType, "application/json") {
var data []byte
var err error
// response body is json
if respBody.Encoding == "base64" {
// decode base64 text
data, err = base64.StdEncoding.DecodeString(respBody.Text)
if err != nil {
return errors.Wrap(err, "decode base64 error")
}
} else if respBody.Encoding == "" {
// no encoding
data = []byte(respBody.Text)
} else {
// other encoding type
return nil
}
// convert to json
var body interface{}
if err = json.Unmarshal(data, &body); err != nil {
return errors.Wrap(err, "json.Unmarshal body error")
}
jsonBody, ok := body.(map[string]interface{})
if !ok {
return fmt.Errorf("response body is not json, not matched with MimeType")
}
// response body is json
keys := make([]string, 0, len(jsonBody))
for k := range jsonBody {
keys = append(keys, k)
}
// sort map keys to keep validators in stable order
sort.Strings(keys)
for _, key := range keys {
value := jsonBody[key]
switch v := value.(type) {
case map[string]interface{}:
continue
case []interface{}:
continue
default:
s.Validators = append(s.Validators, hrp.Validator{
Check: fmt.Sprintf("body.%s", key),
Assert: "equals",
Expect: v,
Message: fmt.Sprintf("assert response body %s", key),
})
}
}
}
return nil
}

View File

@@ -1,281 +0,0 @@
package convert
import (
"testing"
hrp "github.com/httprunner/httprunner/v5"
"github.com/stretchr/testify/assert"
)
var harPath = "../../../examples/data/har/demo.har"
var caseHar *CaseHar
func init() {
caseHar, _ = loadCaseHAR(harPath)
}
func TestLoadHAR(t *testing.T) {
caseHAR, err := loadCaseHAR(harPath)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, "GET", caseHAR.Log.Entries[0].Request.Method) {
t.Fatal()
}
if !assert.Equal(t, "POST", caseHAR.Log.Entries[1].Request.Method) {
t.Fatal()
}
}
func TestLoadTCaseFromHAR(t *testing.T) {
tCase, err := LoadHARCase(harPath)
if !assert.NoError(t, err) {
t.Fatal()
}
// make request method
if !assert.EqualValues(t, "GET", tCase.Steps[0].Request.Method) {
t.Fatal()
}
if !assert.EqualValues(t, "POST", tCase.Steps[1].Request.Method) {
t.Fatal()
}
// make request url
if !assert.Equal(t, "https://postman-echo.com/get", tCase.Steps[0].Request.URL) {
t.Fatal()
}
if !assert.Equal(t, "https://postman-echo.com/post", tCase.Steps[1].Request.URL) {
t.Fatal()
}
// make request params
if !assert.Equal(t, "HDnY8", tCase.Steps[0].Request.Params["foo1"]) {
t.Fatal()
}
// make request cookies
if !assert.NotEmpty(t, tCase.Steps[1].Request.Cookies["sails.sid"]) {
t.Fatal()
}
// make request headers
if !assert.Equal(t, "HttpRunnerPlus", tCase.Steps[0].Request.Headers["User-Agent"]) {
t.Fatal()
}
if !assert.Equal(t, "postman-echo.com", tCase.Steps[0].Request.Headers["Host"]) {
t.Fatal()
}
// make request data
if !assert.Equal(t, nil, tCase.Steps[0].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{"foo1": "HDnY8", "foo2": 12.3}, tCase.Steps[1].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{"foo1": "HDnY8", "foo2": "12.3"}, tCase.Steps[2].Request.Body) {
t.Fatal()
}
// make validators
validator, ok := tCase.Steps[0].Validators[0].(hrp.Validator)
if !ok || !assert.Equal(t, "status_code", validator.Check) {
t.Fatal()
}
validator, ok = tCase.Steps[0].Validators[1].(hrp.Validator)
if !ok || !assert.Equal(t, "headers.\"Content-Type\"", validator.Check) {
t.Fatal()
}
validator, ok = tCase.Steps[0].Validators[2].(hrp.Validator)
if !ok || !assert.Equal(t, "body.url", validator.Check) {
t.Fatal()
}
}
func TestMakeRequestURL(t *testing.T) {
entry := &Entry{
Request: Request{
URL: "http://127.0.0.1:8080/api/login",
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, "http://127.0.0.1:8080/api/login", step.Request.URL) {
t.Fatal()
}
}
func TestMakeRequestHeaders(t *testing.T) {
entry := &Entry{
Request: Request{
Method: "POST",
Headers: []NVP{
{Name: "Content-Type", Value: "application/json; charset=utf-8"},
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"Content-Type": "application/json; charset=utf-8",
}, step.Request.Headers) {
t.Fatal()
}
}
func TestMakeRequestCookies(t *testing.T) {
entry := &Entry{
Request: Request{
Method: "POST",
Cookies: []Cookie{
{Name: "abc", Value: "123"},
{Name: "UserName", Value: "leolee"},
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"abc": "123",
"UserName": "leolee",
}, step.Request.Cookies) {
t.Fatal()
}
}
func TestMakeRequestDataParams(t *testing.T) {
entry := &Entry{
Request: Request{
Method: "POST",
PostData: PostData{
MimeType: "application/x-www-form-urlencoded; charset=utf-8",
Params: []PostParam{
{Name: "a", Value: "1"},
{Name: "b", Value: "2"},
},
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{"a": "1", "b": "2"}, step.Request.Body) {
t.Fatal()
}
}
func TestMakeRequestDataJSON(t *testing.T) {
entry := &Entry{
Request: Request{
Method: "POST",
PostData: PostData{
MimeType: "application/json; charset=utf-8",
Text: "{\"a\":\"1\",\"b\":\"2\"}",
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{"a": "1", "b": "2"}, step.Request.Body) {
t.Fatal()
}
}
func TestMakeRequestDataTextEmpty(t *testing.T) {
entry := &Entry{
Request: Request{
Method: "POST",
PostData: PostData{
MimeType: "application/json; charset=utf-8",
Text: "",
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, nil, step.Request.Body) { // TODO
t.Fatal()
}
}
func TestMakeValidate(t *testing.T) {
entry := &Entry{
Response: Response{
Status: 200,
Headers: []NVP{
{Name: "Content-Type", Value: "application/json; charset=utf-8"},
},
Content: Content{
Size: 71,
MimeType: "application/json; charset=utf-8",
// map[Code:200 IsSuccess:true Message:<nil> Value:map[BlnResult:true]]
Text: "eyJJc1N1Y2Nlc3MiOnRydWUsIkNvZGUiOjIwMCwiTWVzc2FnZSI6bnVsbCwiVmFsdWUiOnsiQmxuUmVzdWx0Ijp0cnVlfX0=",
Encoding: "base64",
},
},
}
step, err := caseHar.prepareTestStep(entry)
if !assert.NoError(t, err) {
t.Fatal()
}
validator, ok := step.Validators[0].(hrp.Validator)
if !ok {
t.Fatal()
}
if !assert.Equal(t, validator,
hrp.Validator{
Check: "status_code",
Expect: 200,
Assert: "equals",
Message: "assert response status code",
}) {
t.Fatal()
}
validator, ok = step.Validators[1].(hrp.Validator)
if !ok {
t.Fatal()
}
if !assert.Equal(t, validator,
hrp.Validator{
Check: "headers.\"Content-Type\"",
Expect: "application/json; charset=utf-8",
Assert: "equals",
Message: "assert response header Content-Type",
}) {
t.Fatal()
}
validator, ok = step.Validators[2].(hrp.Validator)
if !ok {
t.Fatal()
}
if !assert.Equal(t, validator,
hrp.Validator{
Check: "body.Code",
Expect: float64(200), // TODO
Assert: "equals",
Message: "assert response body Code",
}) {
t.Fatal()
}
}

View File

@@ -1 +0,0 @@
package convert

View File

@@ -1,26 +0,0 @@
package convert
import (
hrp "github.com/httprunner/httprunner/v5"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
func LoadJSONCase(path string) (*hrp.TestCaseDef, error) {
log.Info().Str("path", path).Msg("load json case file")
caseJSON := new(hrp.TestCaseDef)
err := hrp.LoadFileObject(path, caseJSON)
if err != nil {
return nil, errors.Wrap(err, "load json file failed")
}
if caseJSON.Steps == nil {
return nil, errors.New("invalid json case file, missing teststeps")
}
err = hrp.ConvertCaseCompatibility(caseJSON)
if err != nil {
return nil, err
}
return caseJSON, nil
}

View File

@@ -1,394 +0,0 @@
package convert
import (
"fmt"
"net/url"
"reflect"
"strings"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/internal/json"
)
// ==================== model definition starts here ====================
/*
Postman Collection format reference:
https://schema.postman.com/json/collection/v2.0.0/collection.json
https://schema.postman.com/json/collection/v2.1.0/collection.json
*/
// CasePostman represents the postman exported file
type CasePostman struct {
Info TInfo `json:"info"`
Items []TItem `json:"item"`
}
// TInfo gives information about the collection
type TInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Schema string `json:"schema"`
}
// TItem contains the detail information of request and expected responses
// item could be defined recursively
type TItem struct {
Items []TItem `json:"item"`
Name string `json:"name"`
Request TRequest `json:"request"`
Responses []TResponse `json:"response"`
}
type TRequest struct {
Method string `json:"method"`
Headers []TField `json:"header"`
Body TBody `json:"body"`
URL TUrl `json:"url"`
Description string `json:"description"`
}
type TResponse struct {
Name string `json:"name"`
OriginalRequest TRequest `json:"originalRequest"`
Status string `json:"status"`
Code int `json:"code"`
Headers []TField `json:"headers"`
Body string `json:"body"`
}
type TUrl struct {
Raw string `json:"raw"`
Protocol string `json:"protocol"`
Path []string `json:"path"`
Description string `json:"description"`
Query []TField `json:"query"`
Variable []TField `json:"variable"`
}
type TField struct {
Key string `json:"key"`
Value string `json:"value"`
Src string `json:"src"`
Description string `json:"description"`
Type string `json:"type"`
Disabled bool `json:"disabled"`
Enable bool `json:"enable"`
}
type TBody struct {
Mode string `json:"mode"`
FormData []TField `json:"formdata"`
URLEncoded []TField `json:"urlencoded"`
Raw string `json:"raw"`
Disabled bool `json:"disabled"`
Options interface{} `json:"options"`
}
// ==================== model definition ends here ====================
const (
enumBodyRaw = "raw"
enumBodyUrlEncoded = "urlencoded"
enumBodyFormData = "formdata"
enumBodyFile = "file"
enumBodyGraphQL = "graphql"
)
const (
enumFieldTypeText = "text"
enumFieldTypeFile = "file"
)
var contentTypeMap = map[string]string{
"text": "text/plain",
"javascript": "application/javascript",
"json": "application/json",
"html": "text/html",
"xml": "application/xml",
}
func LoadPostmanCase(path string) (*hrp.TestCaseDef, error) {
log.Info().Str("path", path).Msg("load postman case file")
casePostman, err := loadCasePostman(path)
if err != nil {
return nil, err
}
// convert to TestCase format
return casePostman.ToTestCase()
}
func loadCasePostman(path string) (*CasePostman, error) {
casePostman := new(CasePostman)
err := hrp.LoadFileObject(path, casePostman)
if err != nil {
return nil, errors.Wrap(err, "load postman file failed")
}
if casePostman.Items == nil {
return nil, errors.New("invalid postman case file, missing items")
}
return casePostman, nil
}
func (c *CasePostman) ToTestCase() (*hrp.TestCaseDef, error) {
teststeps, err := c.prepareTestSteps()
if err != nil {
return nil, err
}
tCase := &hrp.TestCaseDef{
Config: c.prepareConfig(),
Steps: teststeps,
}
err = hrp.ConvertCaseCompatibility(tCase)
if err != nil {
return nil, err
}
return tCase, nil
}
func (c *CasePostman) prepareConfig() *hrp.TConfig {
return hrp.NewConfig(c.Info.Name).
SetVerifySSL(false)
}
func (c *CasePostman) prepareTestSteps() ([]*hrp.TStep, error) {
// recursively convert collection items into a list
var itemList []TItem
for _, item := range c.Items {
extractItemList(item, &itemList)
}
var steps []*hrp.TStep
for _, item := range itemList {
step, err := c.prepareTestStep(&item)
if err != nil {
return nil, err
}
steps = append(steps, step)
}
return steps, nil
}
func extractItemList(item TItem, itemList *[]TItem) {
// current item contains no other items and request is not empty
if len(item.Items) == 0 {
if !reflect.DeepEqual(item.Request, TRequest{}) {
*itemList = append(*itemList, item)
}
return
}
// look up all items inside
for _, i := range item.Items {
// append item name
i.Name = fmt.Sprintf("%s - %s", item.Name, i.Name)
extractItemList(i, itemList)
}
}
func (c *CasePostman) prepareTestStep(item *TItem) (*hrp.TStep, error) {
log.Info().
Str("method", item.Request.Method).
Str("url", item.Request.URL.Raw).
Msg("convert teststep")
step := &stepFromPostman{
TStep: hrp.TStep{
Request: &hrp.Request{},
StepConfig: hrp.StepConfig{
Validators: make([]interface{}, 0),
},
},
}
if err := step.makeRequestName(item); err != nil {
return nil, err
}
if err := step.makeRequestMethod(item); err != nil {
return nil, err
}
if err := step.makeRequestURL(item); err != nil {
return nil, err
}
if err := step.makeRequestParams(item); err != nil {
return nil, err
}
if err := step.makeRequestHeaders(item); err != nil {
return nil, err
}
if err := step.makeRequestCookies(item); err != nil {
return nil, err
}
if err := step.makeRequestBody(item); err != nil {
return nil, err
}
return &step.TStep, nil
}
type stepFromPostman struct {
hrp.TStep
}
// makeRequestName indicates the step name the same as item name
func (s *stepFromPostman) makeRequestName(item *TItem) error {
s.StepName = item.Name
return nil
}
func (s *stepFromPostman) makeRequestMethod(item *TItem) error {
s.Request.Method = hrp.HTTPMethod(item.Request.Method)
return nil
}
func (s *stepFromPostman) makeRequestURL(item *TItem) error {
rawUrl := item.Request.URL.Raw
// parse path variables like ":path" in https://postman-echo.com/:path?k1=v1&k2=v2
for _, field := range item.Request.URL.Variable {
pathVar := ":" + field.Key
rawUrl = strings.Replace(rawUrl, pathVar, field.Value, -1)
}
u, err := url.Parse(rawUrl)
if err != nil {
return errors.Wrap(err, "parse URL error")
}
s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path)
return nil
}
func (s *stepFromPostman) makeRequestParams(item *TItem) error {
s.Request.Params = make(map[string]interface{})
for _, field := range item.Request.URL.Query {
if field.Disabled {
continue
}
s.Request.Params[field.Key] = field.Value
}
return nil
}
func (s *stepFromPostman) makeRequestHeaders(item *TItem) error {
// headers defined in postman collection
s.Request.Headers = make(map[string]string)
for _, field := range item.Request.Headers {
if field.Disabled || strings.EqualFold(field.Key, "cookie") {
continue
}
s.Request.Headers[field.Key] = field.Value
}
return nil
}
func (s *stepFromPostman) makeRequestCookies(item *TItem) error {
// cookies defined in postman collection
s.Request.Cookies = make(map[string]string)
for _, field := range item.Request.Headers {
if field.Disabled || !strings.EqualFold(field.Key, "cookie") {
continue
}
s.parseRequestCookiesMap(field.Value)
}
return nil
}
func (s *stepFromPostman) parseRequestCookiesMap(cookies string) {
for _, cookie := range strings.Split(cookies, ";") {
cookie = strings.TrimSpace(cookie)
index := strings.Index(cookie, "=")
if index == -1 {
log.Warn().Str("cookie", cookie).Msg("cookie format invalid")
continue
}
s.Request.Cookies[cookie[:index]] = cookie[index+1:]
}
}
func (s *stepFromPostman) makeRequestBody(item *TItem) error {
mode := item.Request.Body.Mode
if mode == "" {
return nil
}
switch mode {
case enumBodyRaw:
return s.makeRequestBodyRaw(item)
case enumBodyFormData:
return s.makeRequestBodyFormData(item)
case enumBodyUrlEncoded:
return s.makeRequestBodyUrlEncoded(item)
case enumBodyFile, enumBodyGraphQL:
return errors.Errorf("unsupported body type: %v", mode)
}
return nil
}
func (s *stepFromPostman) makeRequestBodyRaw(item *TItem) (err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("make request body (raw) failed: %v", p)
}
}()
languageType := "text"
iOptions := item.Request.Body.Options
if iOptions != nil {
iLanguage := iOptions.(map[string]interface{})["raw"]
if iLanguage != nil {
languageType = iLanguage.(map[string]interface{})["language"].(string)
}
}
s.Request.Body = item.Request.Body.Raw
contentType := s.Request.Headers["Content-Type"]
if strings.Contains(contentType, "application/json") || languageType == "json" {
var iBody interface{}
err = json.Unmarshal([]byte(item.Request.Body.Raw), &iBody)
if err != nil {
return errors.Wrap(err, "make request body (raw -> json) failed")
}
s.Request.Body = iBody
}
if contentType == "" {
s.Request.Headers["Content-Type"] = contentTypeMap[languageType]
}
return
}
func (s *stepFromPostman) makeRequestBodyFormData(item *TItem) error {
s.Request.Upload = make(map[string]interface{})
for _, field := range item.Request.Body.FormData {
if field.Disabled {
continue
}
// form data could be text or file
if field.Type == enumFieldTypeText {
s.Request.Upload[field.Key] = field.Value
} else if field.Type == enumFieldTypeFile {
s.Request.Upload[field.Key] = field.Src
} else {
return errors.Errorf("make request body form data failed: unexpect field type: %v", field.Type)
}
}
return nil
}
func (s *stepFromPostman) makeRequestBodyUrlEncoded(item *TItem) error {
payloadMap := make(map[string]string)
for _, field := range item.Request.Body.URLEncoded {
if field.Disabled {
continue
}
payloadMap[field.Key] = field.Value
}
s.Request.Body = payloadMap
s.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"
return nil
}
// TODO makeValidate from test scripts
func (s *stepFromPostman) makeValidate(item *TItem) error {
return nil
}

View File

@@ -1,78 +0,0 @@
package convert
import (
"testing"
"github.com/stretchr/testify/assert"
)
var collectionPath = "../../../examples/data/postman/postman_collection.json"
func TestLoadCollection(t *testing.T) {
casePostman, err := loadCasePostman(collectionPath)
if !assert.NoError(t, err) {
t.Fatal(err)
}
if !assert.Equal(t, "postman collection demo", casePostman.Info.Name) {
t.Fatal()
}
}
func TestMakeTestCaseFromCollection(t *testing.T) {
tCase, err := LoadPostmanCase(collectionPath)
if !assert.NoError(t, err) {
t.Fatal()
}
// check name
if !assert.Equal(t, "postman collection demo", tCase.Config.Name) {
t.Fatal()
}
// check method
if !assert.EqualValues(t, "GET", tCase.Steps[0].Request.Method) {
t.Fatal()
}
if !assert.EqualValues(t, "POST", tCase.Steps[1].Request.Method) {
t.Fatal()
}
// check url
if !assert.Equal(t, "https://postman-echo.com/get", tCase.Steps[0].Request.URL) {
t.Fatal()
}
if !assert.Equal(t, "https://postman-echo.com/post", tCase.Steps[1].Request.URL) {
t.Fatal()
}
// check params
if !assert.Equal(t, "v1", tCase.Steps[0].Request.Params["k1"]) {
t.Fatal()
}
// check cookies (pass, postman collection doesn't contain cookies)
// check headers
if !assert.Equal(t, "application/x-www-form-urlencoded", tCase.Steps[2].Request.Headers["Content-Type"]) {
t.Fatal()
}
if !assert.Equal(t, "application/json", tCase.Steps[3].Request.Headers["Content-Type"]) {
t.Fatal()
}
if !assert.Equal(t, "text/plain", tCase.Steps[4].Request.Headers["Content-Type"]) {
t.Fatal()
}
if !assert.Equal(t, "HttpRunner", tCase.Steps[5].Request.Headers["User-Agent"]) {
t.Fatal()
}
// check body
if !assert.Equal(t, nil, tCase.Steps[0].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{"k1": "v1", "k2": "v2"}, tCase.Steps[2].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, map[string]interface{}{"k1": "v1", "k2": "v2"}, tCase.Steps[3].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, "have a nice day", tCase.Steps[4].Request.Body) {
t.Fatal()
}
if !assert.Equal(t, nil, tCase.Steps[5].Request.Body) {
t.Fatal()
}
}

View File

@@ -1 +0,0 @@
package convert

View File

@@ -1,22 +0,0 @@
package convert
import (
"github.com/go-openapi/spec"
hrp "github.com/httprunner/httprunner/v5"
"github.com/pkg/errors"
)
func LoadSwaggerCase(path string) (*hrp.TestCaseDef, error) {
// load swagger file
caseSwagger := new(spec.Swagger)
err := hrp.LoadFileObject(path, caseSwagger)
if err != nil {
return nil, errors.Wrap(err, "load swagger file failed")
}
if caseSwagger.Definitions == nil {
return nil, errors.New("invalid swagger case file, missing definitions")
}
// TODO: convert swagger to TCase
return nil, nil
}

View File

@@ -1,26 +0,0 @@
package convert
import (
"reflect"
hrp "github.com/httprunner/httprunner/v5"
"github.com/pkg/errors"
)
func LoadYAMLCase(path string) (*hrp.TestCaseDef, error) {
// load yaml case file
caseJSON := new(hrp.TestCaseDef)
err := hrp.LoadFileObject(path, caseJSON)
if err != nil {
return nil, errors.Wrap(err, "load yaml file failed")
}
if reflect.ValueOf(*caseJSON).IsZero() {
return nil, errors.New("invalid yaml file")
}
err = hrp.ConvertCaseCompatibility(caseJSON)
if err != nil {
return nil, err
}
return caseJSON, nil
}

View File

@@ -1,230 +0,0 @@
package convert
import (
"path/filepath"
"time"
"github.com/rs/zerolog/log"
hrp "github.com/httprunner/httprunner/v5"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/internal/sdk"
)
// target testcase format extensions
const (
suffixJSON = ".json"
suffixYAML = ".yaml"
suffixGoTest = ".go"
suffixPyTest = ".py"
suffixHAR = ".har"
)
type FromType int
const (
FromTypeJSON FromType = iota
FromTypeYAML
FromTypeHAR
FromTypePostman
FromTypeCurl
FromTypeSwagger
FromTypePyest
FromTypeGotest
)
func (fromType FromType) String() string {
switch fromType {
case FromTypeYAML:
return "yaml"
case FromTypeHAR:
return "har"
case FromTypePostman:
return "postman"
case FromTypeSwagger:
return "swagger"
case FromTypeCurl:
return "curl"
case FromTypeGotest:
return "gotest"
case FromTypePyest:
return "pytest"
default:
return "json"
}
}
func (fromType FromType) Extensions() []string {
switch fromType {
case FromTypeYAML:
return []string{suffixYAML, ".yml"}
case FromTypeHAR:
return []string{suffixHAR}
case FromTypePostman, FromTypeSwagger:
return []string{suffixJSON}
case FromTypeCurl:
return []string{".txt", ".curl"}
case FromTypeGotest:
return []string{suffixGoTest}
case FromTypePyest:
return []string{suffixPyTest}
default:
return []string{suffixJSON}
}
}
type OutputType int
const (
OutputTypeJSON OutputType = iota // default output type: JSON
OutputTypeYAML
OutputTypeGoTest
OutputTypePyTest
)
func (outputType OutputType) String() string {
switch outputType {
case OutputTypeYAML:
return "yaml"
case OutputTypeGoTest:
return "gotest"
case OutputTypePyTest:
return "pytest"
default:
return "json"
}
}
// Profile is used to override or update(create if not existed) original headers and cookies
type Profile struct {
Override bool `json:"override" yaml:"override"`
Headers map[string]string `json:"headers" yaml:"headers"`
Cookies map[string]string `json:"cookies" yaml:"cookies"`
}
func NewConverter(outputDir, profilePath string) *TCaseConverter {
return &TCaseConverter{
profilePath: profilePath,
outputDir: outputDir,
}
}
// TCaseConverter holds the common properties of case converter
type TCaseConverter struct {
fromFile string
profilePath string
outputDir string
tCase *hrp.TestCaseDef
}
// LoadCase loads source file and convert to TCase type
func (c *TCaseConverter) loadCase(casePath string, fromType FromType) error {
c.fromFile = casePath
var err error
switch fromType {
case FromTypeJSON:
c.tCase, err = LoadJSONCase(casePath)
case FromTypeYAML:
c.tCase, err = LoadYAMLCase(casePath)
case FromTypeHAR:
c.tCase, err = LoadHARCase(casePath)
case FromTypePostman:
c.tCase, err = LoadPostmanCase(casePath)
case FromTypeSwagger:
c.tCase, err = LoadSwaggerCase(casePath)
case FromTypeCurl:
c.tCase, err = LoadCurlCase(casePath)
}
return err
}
func (c *TCaseConverter) Convert(casePath string, fromType FromType, outputType OutputType) (err error) {
// report GA event
startTime := time.Now()
defer func() {
sdk.SendGA4Event("hrp_convert", map[string]interface{}{
"from": fromType.String(),
"to": outputType.String(),
"success": err == nil,
"engagement_time_msec": time.Since(startTime).Milliseconds(),
})
}()
log.Info().Str("path", casePath).
Str("fromType", fromType.String()).
Str("outputType", outputType.String()).
Msg("convert testcase")
// load source file
err = c.loadCase(casePath, fromType)
if err != nil {
return err
}
// override TCase with profile
if c.profilePath != "" {
c.overrideWithProfile(c.profilePath)
}
// convert to target format
var outputFile string
switch outputType {
case OutputTypeYAML:
outputFile, err = c.toYAML()
case OutputTypeGoTest:
outputFile, err = c.toGoTest()
case OutputTypePyTest:
outputFile, err = c.toPyTest()
default:
outputFile, err = c.toJSON()
}
if err != nil {
return err
}
log.Info().Str("outputFile", outputFile).Msg("conversion completed")
return nil
}
func (c *TCaseConverter) genOutputPath(suffix string) string {
outFileFullName := builtin.GetFileNameWithoutExtension(c.fromFile) + "_test" + suffix
if c.outputDir != "" {
return filepath.Join(c.outputDir, outFileFullName)
} else {
return filepath.Join(filepath.Dir(c.fromFile), outFileFullName)
}
}
func (c *TCaseConverter) overrideWithProfile(path string) error {
log.Info().Str("path", path).Msg("load profile")
profile := new(Profile)
err := hrp.LoadFileObject(path, profile)
if err != nil {
log.Warn().Str("path", path).
Msg("failed to load profile, ignore!")
return err
}
log.Info().Interface("profile", profile).Msg("override with profile")
for _, step := range c.tCase.Steps {
// override original headers and cookies
if profile.Override {
step.Request.Headers = make(map[string]string)
step.Request.Cookies = make(map[string]string)
}
// update (create if not existed) original headers and cookies
if step.Request.Headers == nil {
step.Request.Headers = make(map[string]string)
}
if step.Request.Cookies == nil {
step.Request.Cookies = make(map[string]string)
}
for k, v := range profile.Headers {
step.Request.Headers[k] = v
}
for k, v := range profile.Cookies {
step.Request.Cookies[k] = v
}
}
return nil
}

View File

@@ -1,137 +0,0 @@
package convert
import (
"testing"
hrp "github.com/httprunner/httprunner/v5"
"github.com/stretchr/testify/assert"
)
const (
profilePath = "../../../examples/data/profile.yml"
profileOverridePath = "../../../examples/data/profile_override.yml"
)
var converter *TCaseConverter
func init() {
converter = NewConverter("", "")
}
func TestLoadTCase(t *testing.T) {
err := converter.loadCase(harPath, FromTypeHAR)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.NotEmpty(t, converter.tCase) {
t.Fatal()
}
}
func TestLoadHARWithProfileOverride(t *testing.T) {
err := converter.loadCase(harPath, FromTypeHAR)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.NotEmpty(t, converter.tCase) {
t.Fatal()
}
// override TCase with profile
err = converter.overrideWithProfile(profileOverridePath)
if !assert.NoError(t, err) {
t.Fatal()
}
for i := 0; i < 3; i++ {
assert.Equal(t,
map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
converter.tCase.Steps[i].Request.Headers)
assert.Equal(t,
map[string]string{"UserName": "debugtalk"},
converter.tCase.Steps[i].Request.Cookies)
}
}
func TestMakeRequestWithProfile(t *testing.T) {
caseConverter := &TCaseConverter{
tCase: &hrp.TestCaseDef{
Steps: []*hrp.TStep{
{
Request: &hrp.Request{
Method: hrp.HTTPMethod("POST"),
Headers: map[string]string{
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "hrp",
},
Cookies: map[string]string{
"abc": "123",
"UserName": "leolee",
},
},
},
},
},
}
err := caseConverter.overrideWithProfile(profilePath)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "hrp",
}, caseConverter.tCase.Steps[0].Request.Headers) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"UserName": "debugtalk", "abc": "123",
}, caseConverter.tCase.Steps[0].Request.Cookies) {
t.Fatal()
}
}
func TestMakeRequestWithProfileOverride(t *testing.T) {
caseConverter := &TCaseConverter{
tCase: &hrp.TestCaseDef{
Steps: []*hrp.TStep{
{
Request: &hrp.Request{
Method: hrp.HTTPMethod("POST"),
Headers: map[string]string{
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "hrp",
},
Cookies: map[string]string{
"abc": "123",
"UserName": "leolee",
},
},
},
},
},
}
// override TCase with profile
err := caseConverter.overrideWithProfile(profileOverridePath)
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.NoError(t, err) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
}, caseConverter.tCase.Steps[0].Request.Headers) {
t.Fatal()
}
if !assert.Equal(t, map[string]string{
"UserName": "debugtalk",
}, caseConverter.tCase.Steps[0].Request.Cookies) {
t.Fatal()
}
}

View File

@@ -1,38 +0,0 @@
# NOTE: Generated By HttpRunner v{{ version }}
# FROM: {{ testcase_path }}
{% if imports_list and diff_levels > 0 %}
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__){% for _ in range(diff_levels) %}.parent{% endfor %}))
{% endif %}
{% if parameters %}
import pytest
from httprunner import Parameters
{% endif %}
from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase
{% for import_str in imports_list %}
{{ import_str }}
{% endfor %}
class {{ class_name }}(HttpRunner):
{% if parameters %}
@pytest.mark.parametrize("param", Parameters({{parameters}}))
def test_start(self, param):
super().test_start(param)
{% endif %}
config = {{ config_chain_style }}
teststeps = [
{% for step_chain_style in teststeps_chain_style %}
{{ step_chain_style }},
{% endfor %}
]
if __name__ == "__main__":
{{ class_name }}().test_start()

View File

@@ -1,6 +0,0 @@
package convert
// TODO: convert TCase to gotest case
func (c *TCaseConverter) toGoTest() (string, error) {
return "", nil
}

View File

@@ -1,13 +0,0 @@
package convert
import "github.com/httprunner/httprunner/v5/internal/builtin"
// convert TCase to JSON case
func (c *TCaseConverter) toJSON() (string, error) {
jsonPath := c.genOutputPath(suffixJSON)
err := builtin.Dump2JSON(c.tCase, jsonPath)
if err != nil {
return "", err
}
return jsonPath, nil
}

View File

@@ -1,21 +0,0 @@
package convert
import (
"github.com/httprunner/funplugin/myexec"
"github.com/pkg/errors"
)
// convert TCase to pytest case
func (c *TCaseConverter) toPyTest() (string, error) {
jsonPath, err := c.toJSON()
if err != nil {
return "", errors.Wrap(err, "convert to JSON case failed")
}
args := append([]string{"make"}, jsonPath)
err = myexec.ExecPython3Command("httprunner", args...)
if err != nil {
return "", err
}
return c.genOutputPath(suffixPyTest), nil
}

View File

@@ -1,13 +0,0 @@
package convert
import "github.com/httprunner/httprunner/v5/internal/builtin"
// convert TCase to YAML case
func (c *TCaseConverter) toYAML() (string, error) {
yamlPath := c.genOutputPath(suffixYAML)
err := builtin.Dump2YAML(c.tCase, yamlPath)
if err != nil {
return "", err
}
return yamlPath, nil
}

View File

@@ -1,38 +0,0 @@
package demo
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/httprunner/httprunner/v5/pkg/httpstat"
)
func TestMain(t *testing.T) {
var httpStat httpstat.Stat
req, _ := http.NewRequest("GET", "https://httprunner.com", nil)
ctx := httpstat.WithHTTPStat(req, &httpStat)
client := &http.Client{
Timeout: time.Second * 10,
}
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
if resp != nil {
defer resp.Body.Close()
}
// get stat
httpStat.Finish()
result := httpStat.Durations()
fmt.Println(result)
// print stat
httpStat.Print()
}

View File

@@ -1,276 +0,0 @@
// Package httpstat traces HTTP latency infomation (DNSLookup, TCP Connection and so on) on any golang HTTP request.
// It uses `httptrace` package.
// Inspired by https://github.com/tcnksm/go-httpstat and https://github.com/davecheney/httpstat
package httpstat
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"net/http/httptrace"
"strconv"
"strings"
"sync"
"time"
"github.com/fatih/color"
"github.com/rs/zerolog/log"
)
const (
httpsTemplate = "\n" +
` DNS Lookup TCP Connection TLS Handshake Server Processing Content Transfer` + "\n" +
`[%s | %s | %s | %s | %s ]` + "\n" +
` | | | | |` + "\n" +
` namelookup:%s | | | |` + "\n" +
` connect:%s | | |` + "\n" +
` pretransfer:%s | |` + "\n" +
` starttransfer:%s |` + "\n" +
` total:%s` + "\n\n"
httpTemplate = "\n" +
` DNS Lookup TCP Connection Server Processing Content Transfer` + "\n" +
`[ %s | %s | %s | %s ]` + "\n" +
` | | | |` + "\n" +
` namelookup:%s | | |` + "\n" +
` connect:%s | |` + "\n" +
` starttransfer:%s |` + "\n" +
` total:%s` + "\n\n"
)
func fmta(d time.Duration) string {
return color.YellowString("%7dms", int(d.Milliseconds()))
}
func fmtb(d time.Duration) string {
return color.RedString("%-9s", strconv.Itoa(int(d.Milliseconds()))+"ms")
}
func grayscale(code color.Attribute) func(string, ...interface{}) string {
return color.New(code + 232).SprintfFunc()
}
func colorize(s string) string {
v := strings.Split(s, "\n")
v[0] = grayscale(16)(v[0])
return strings.Join(v, "\n")
}
func printf(format string, a ...interface{}) (n int, err error) {
return fmt.Fprintf(color.Output, format, a...)
}
// Stat stores httpstat info.
type Stat struct {
// The following are duration for each phase
// DNSLookup => TCPConnection => TLSHandshake => ServerProcessing => ContentTransfer
DNSLookup time.Duration
TCPConnection time.Duration
TLSHandshake time.Duration
ServerProcessing time.Duration
ContentTransfer time.Duration // from the first response byte to tansfer done.
// The followings are timeline of request
NameLookup time.Duration // = DNSLookup
Connect time.Duration // = DNSLookup + TCPConnection
Pretransfer time.Duration // = DNSLookup + TCPConnection + TLSHandshake
StartTransfer time.Duration // = DNSLookup + TCPConnection + TLSHandshake + ServerProcessing
Total time.Duration // = DNSLookup + TCPConnection + TLSHandshake + ServerProcessing + ContentTransfer
// internal timelines, including start and finish timestamps of each phase
dnsStart time.Time
dnsDone time.Time
tcpStart time.Time
tcpDone time.Time
tlsStart time.Time
tlsDone time.Time
serverStart time.Time
serverDone time.Time
transferStart time.Time
transferDone time.Time // need to be provided from outside
// isTLS is true when connection seems to use TLS
isTLS bool
// isReused is true when connection is reused (keep-alive)
isReused bool
// https or http
schema string
// connected network info
network, addr string
mux *sync.RWMutex // avoid data race
}
// Finish sets the time when reading response is done.
// This must be called after reading response body.
func (s *Stat) Finish() {
s.transferDone = time.Now()
// This means result is empty (it does nothing).
// Skip setting value (contentTransfer and total will be zero).
if s.dnsStart.IsZero() {
return
}
s.ContentTransfer = s.transferDone.Sub(s.transferStart)
s.Total = s.transferDone.Sub(s.dnsStart)
}
// Durations returns all durations and timelines of request latencies
func (s *Stat) Durations() map[string]int64 {
return map[string]int64{
"DNSLookup": s.DNSLookup.Milliseconds(),
"TCPConnection": s.TCPConnection.Milliseconds(),
"TLSHandshake": s.TLSHandshake.Milliseconds(),
"ServerProcessing": s.ServerProcessing.Milliseconds(),
"ContentTransfer": s.ContentTransfer.Milliseconds(),
"NameLookup": s.NameLookup.Milliseconds(),
"Connect": s.Connect.Milliseconds(),
"Pretransfer": s.Pretransfer.Milliseconds(),
"StartTransfer": s.StartTransfer.Milliseconds(),
"Total": s.Total.Milliseconds(),
}
}
func (s *Stat) Print() {
if s.network != "" && s.addr != "" {
printf("\n%s %s: %s\n",
color.CyanString("Connected to"),
color.MagentaString(s.network),
color.BlueString(s.addr),
)
}
switch s.schema {
case "https":
printf(colorize(httpsTemplate),
fmta(s.DNSLookup), // dns lookup
fmta(s.TCPConnection), // tcp connection
fmta(s.TLSHandshake), // tls handshake
fmta(s.ServerProcessing), // server processing
fmta(s.ContentTransfer), // content transfer
fmtb(s.NameLookup), // namelookup
fmtb(s.Connect), // connect
fmtb(s.Pretransfer), // pretransfer
fmtb(s.StartTransfer), // starttransfer
fmtb(s.Total), // total
)
case "http":
printf(colorize(httpTemplate),
fmta(s.DNSLookup), // dns lookup
fmta(s.TCPConnection), // tcp connection
fmta(s.ServerProcessing), // server processing
fmta(s.ContentTransfer), // content transfer
fmtb(s.NameLookup), // namelookup
fmtb(s.Connect), // connect
fmtb(s.StartTransfer), // starttransfer
fmtb(s.Total), // total
)
}
log.Info().
Interface("httpstat(ms)", s.Durations()).
Msg("HTTP latency statistics")
}
// WithHTTPStat is a wrapper of httptrace.WithClientTrace.
// It records the time of each httptrace hooks.
func WithHTTPStat(req *http.Request, s *Stat) context.Context {
s.mux = new(sync.RWMutex)
s.schema = req.URL.Scheme
return httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{
DNSStart: func(i httptrace.DNSStartInfo) {
s.dnsStart = time.Now()
},
DNSDone: func(i httptrace.DNSDoneInfo) {
s.dnsDone = time.Now()
s.DNSLookup = s.dnsDone.Sub(s.dnsStart)
s.NameLookup = s.DNSLookup
},
ConnectStart: func(network, addr string) {
s.network = network
s.addr = addr
s.tcpStart = time.Now()
// When connecting to IP (When no DNS lookup)
if s.dnsStart.IsZero() {
s.dnsStart = s.tcpStart
s.dnsDone = s.tcpStart
}
},
ConnectDone: func(network, addr string, err error) {
s.tcpDone = time.Now()
s.TCPConnection = s.tcpDone.Sub(s.tcpStart)
s.Connect = s.tcpDone.Sub(s.dnsStart)
},
TLSHandshakeStart: func() {
s.isTLS = true
s.tlsStart = time.Now()
},
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) {
s.tlsDone = time.Now()
s.TLSHandshake = s.tlsDone.Sub(s.tlsStart)
s.Pretransfer = s.tlsDone.Sub(s.dnsStart)
},
GotConn: func(i httptrace.GotConnInfo) {
// Handle when keep alive is used and connection is reused.
// DNSStart(Done) and ConnectStart(Done) is skipped
if i.Reused {
s.isReused = true
}
},
WroteRequest: func(info httptrace.WroteRequestInfo) {
s.mux.Lock()
defer s.mux.Unlock()
now := time.Now()
s.serverStart = now
// When client doesn't use DialContext, DNS/TCP/TLS hook is not called.
if s.dnsStart.IsZero() && s.tcpStart.IsZero() {
s.dnsStart = now
s.dnsDone = now
s.tcpStart = now
s.tcpDone = now
}
// When connection is re-used, DNS/TCP/TLS hook is not called.
if s.isReused {
s.dnsStart = now
s.dnsDone = now
s.tcpStart = now
s.tcpDone = now
s.tlsStart = now
s.tlsDone = now
}
if s.isTLS { // https
return
}
// http
s.TLSHandshake = 0
s.Pretransfer = s.Connect
},
GotFirstResponseByte: func() {
s.mux.Lock()
defer s.mux.Unlock()
s.serverDone = time.Now()
s.ServerProcessing = s.serverDone.Sub(s.serverStart)
s.StartTransfer = s.serverDone.Sub(s.dnsStart)
s.transferStart = s.serverDone
},
})
}

View File

@@ -1,34 +0,0 @@
# uixt
From v4.3.0HttpRunner will support mobile UI automation testing:
- iOS: based on [appium/WebDriverAgent], with forked client library [electricbubble/gwda] in golang
- Android: based on [appium-uiautomator2-server], with forked client library [electricbubble/guia2] in golang
Some UI recognition algorithms are also introduced for both iOS and Android:
- OCR: based on OCR API service from [volcengine], other API service may be extended
## Dependencies
### OCR
OCR API is a paid service, you need to pre-purchase and configure the environment variables.
- VEDEM_IMAGE_URL
- VEDEM_IMAGE_AK
- VEDEM_IMAGE_SK
## Thanks
This uixt module is initially forked from the following repos and made a lot of changes.
- [electricbubble/gwda]
- [electricbubble/guia2]
[appium/WebDriverAgent]: https://github.com/appium/WebDriverAgent
[electricbubble/gwda]: https://github.com/electricbubble/gwda
[electricbubble/guia2]: https://github.com/electricbubble/guia2
[volcengine]: https://www.volcengine.com/product/text-recognition
[appium-uiautomator2-server]: https://github.com/appium/appium-uiautomator2-server

View File

@@ -1,63 +0,0 @@
package ai
import (
"os"
"github.com/httprunner/httprunner/v5/code"
"github.com/rs/zerolog/log"
)
func NewAIService(opts ...AIServiceOption) *AIServices {
services := &AIServices{}
for _, option := range opts {
option(services)
}
return services
}
type AIServices struct {
ICVService
ILLMService
}
type AIServiceOption func(*AIServices)
type CVServiceType string
const (
CVServiceTypeVEDEM CVServiceType = "vedem"
CVServiceTypeOpenCV CVServiceType = "opencv"
)
func WithCVService(service CVServiceType) AIServiceOption {
return func(opts *AIServices) {
if service == CVServiceTypeVEDEM {
var err error
opts.ICVService, err = NewVEDEMImageService()
if err != nil {
log.Error().Err(err).Msg("init vedem image service failed")
os.Exit(code.GetErrorCode(err))
}
}
}
}
type LLMServiceType string
const (
LLMServiceTypeGPT4o LLMServiceType = "gpt-4o"
LLMServiceTypeDeepSeekV3 LLMServiceType = "deepseek-v3"
)
func WithLLMService(service LLMServiceType) AIServiceOption {
return func(opts *AIServices) {
if service == LLMServiceTypeGPT4o {
var err error
opts.ILLMService, err = NewGPT4oLLMService()
if err != nil {
log.Error().Err(err).Msg("init gpt-4o llm service failed")
os.Exit(code.GetErrorCode(err))
}
}
}
}

View File

@@ -1,11 +0,0 @@
package ai
import "testing"
func TestOption(t *testing.T) {
options := NewAIService(
WithCVService(CVServiceTypeOpenCV),
WithLLMService(LLMServiceTypeDeepSeekV3),
)
t.Log(options)
}

View File

@@ -1,318 +0,0 @@
package ai
import (
"bytes"
"fmt"
"image"
"math"
"regexp"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
"github.com/pkg/errors"
)
type ICVService interface {
// returns CV result including ocr texts, uploaded image url, etc
ReadFromBuffer(imageBuf *bytes.Buffer, opts ...option.ActionOption) (*CVResult, error)
ReadFromPath(imagePath string, opts ...option.ActionOption) (*CVResult, error)
}
type CVResult struct {
URL string `json:"url,omitempty"` // image uploaded url
OCRResult OCRResults `json:"ocrResult,omitempty"` // OCR texts
// NoLive非直播间
// Shop电商
// LifeService生活服务
// Show秀场
// Game游戏
// People多人
// PKPK
// Media媒体
// Chat语音
// Event赛事
LiveType string `json:"liveType,omitempty"` // 直播间类型
LivePopularity int64 `json:"livePopularity,omitempty"` // 直播间热度
UIResult UIResultMap `json:"uiResult,omitempty"` // 图标检测
ClosePopupsResult *ClosePopupsResult `json:"closeResult,omitempty"` // 弹窗按钮检测
}
type OCRResult struct {
Text string `json:"text"`
Points []PointF `json:"points"`
}
type OCRResults []OCRResult
func (o OCRResults) ToOCRTexts() (ocrTexts OCRTexts) {
for _, ocrResult := range o {
rect := image.Rectangle{
// ocrResult.Points 顺序:左上 -> 右上 -> 右下 -> 左下
Min: image.Point{
X: int(ocrResult.Points[0].X),
Y: int(ocrResult.Points[0].Y),
},
Max: image.Point{
X: int(ocrResult.Points[2].X),
Y: int(ocrResult.Points[2].Y),
},
}
rectStr := fmt.Sprintf("%d,%d,%d,%d",
rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y)
ocrText := OCRText{
Text: ocrResult.Text,
Rect: rect,
RectStr: rectStr,
}
ocrTexts = append(ocrTexts, ocrText)
}
return
}
type OCRText struct {
Text string `json:"text"`
RectStr string `json:"rect"`
Rect image.Rectangle `json:"-"`
}
func (t OCRText) Size() types.Size {
return types.Size{
Width: t.Rect.Dx(),
Height: t.Rect.Dy(),
}
}
func (t OCRText) Center() PointF {
return getRectangleCenterPoint(t.Rect)
}
func getRectangleCenterPoint(rect image.Rectangle) (point PointF) {
x, y := float64(rect.Min.X), float64(rect.Min.Y)
width, height := float64(rect.Dx()), float64(rect.Dy())
point = PointF{
X: x + width*0.5,
Y: y + height*0.5,
}
return point
}
type OCRTexts []OCRText
func (t OCRTexts) texts() (texts []string) {
for _, text := range t {
texts = append(texts, text.Text)
}
return texts
}
func (t OCRTexts) FilterScope(scope option.AbsScope) (results OCRTexts) {
for _, ocrText := range t {
rect := ocrText.Rect
// check if text in scope
if len(scope) == 4 {
if rect.Min.X < scope[0] ||
rect.Min.Y < scope[1] ||
rect.Max.X > scope[2] ||
rect.Max.Y > scope[3] {
// not in scope
continue
}
}
results = append(results, ocrText)
}
return
}
// FindText returns matched text with options
// Notice: filter scope should be specified with WithAbsScope
func (t OCRTexts) FindText(text string, opts ...option.ActionOption) (result OCRText, err error) {
options := option.NewActionOptions(opts...)
var results []OCRText
for _, ocrText := range t.FilterScope(options.AbsScope) {
if options.Regex {
// regex on, check if match regex
if !regexp.MustCompile(text).MatchString(ocrText.Text) {
continue
}
} else {
// regex off, check if match exactly
if ocrText.Text != text {
continue
}
}
results = append(results, ocrText)
// return the first one matched exactly when index not specified
if ocrText.Text == text && options.Index == 0 {
return ocrText, nil
}
}
if len(results) == 0 {
return OCRText{}, errors.Wrap(code.CVResultNotFoundError,
fmt.Sprintf("text %s not found in %v", text, t.texts()))
}
// get index
idx := options.Index
if idx < 0 {
idx = len(results) + idx
}
// index out of range
if idx >= len(results) || idx < 0 {
return OCRText{}, errors.Wrap(code.CVResultNotFoundError,
fmt.Sprintf("text %s found %d, index %d out of range", text, len(results), idx))
}
return results[idx], nil
}
func (t OCRTexts) FindTexts(texts []string, opts ...option.ActionOption) (results OCRTexts, err error) {
options := option.NewActionOptions(opts...)
for _, text := range texts {
ocrText, err := t.FindText(text, opts...)
if err != nil {
continue
}
results = append(results, ocrText)
// found one, skip searching and return
if options.MatchOne {
return results, nil
}
}
if len(results) == len(texts) {
return results, nil
}
return nil, errors.Wrap(code.CVResultNotFoundError,
fmt.Sprintf("texts %s not found in %v", texts, t.texts()))
}
type UIResultMap map[string]UIResults
// FilterUIResults filters ui icons, the former the uiTypes, the higher the priority
func (u UIResultMap) FilterUIResults(uiTypes []string) (uiResults UIResults, err error) {
var ok bool
for _, uiType := range uiTypes {
uiResults, ok = u[uiType]
if ok && len(uiResults) != 0 {
return
}
}
err = errors.Wrap(code.CVResultNotFoundError, fmt.Sprintf("UI types %v not detected", uiTypes))
return
}
type UIResult struct {
Box
}
type Box struct {
Point PointF `json:"point"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
func (box Box) IsEmpty() bool {
return builtin.IsZeroFloat64(box.Width) && builtin.IsZeroFloat64(box.Height)
}
func (box Box) IsIdentical(box2 Box) bool {
// set the coordinate precision to 1 pixel
return box.Point.IsIdentical(box2.Point) &&
builtin.IsZeroFloat64(math.Abs(box.Width-box2.Width)) &&
builtin.IsZeroFloat64(math.Abs(box.Height-box2.Height))
}
func (box Box) Center() PointF {
return PointF{
X: box.Point.X + box.Width*0.5,
Y: box.Point.Y + box.Height*0.5,
}
}
type UIResults []UIResult
func (u UIResults) FilterScope(scope option.AbsScope) (results UIResults) {
for _, uiResult := range u {
rect := image.Rectangle{
Min: image.Point{
X: int(uiResult.Point.X),
Y: int(uiResult.Point.Y),
},
Max: image.Point{
X: int(uiResult.Point.X + uiResult.Width),
Y: int(uiResult.Point.Y + uiResult.Height),
},
}
// check if ui result in scope
if len(scope) == 4 {
if rect.Min.X < scope[0] ||
rect.Min.Y < scope[1] ||
rect.Max.X > scope[2] ||
rect.Max.Y > scope[3] {
// not in scope
continue
}
}
results = append(results, uiResult)
}
return
}
func (u UIResults) GetUIResult(opts ...option.ActionOption) (UIResult, error) {
options := option.NewActionOptions(opts...)
uiResults := u.FilterScope(options.AbsScope)
if len(uiResults) == 0 {
return UIResult{}, errors.Wrap(code.CVResultNotFoundError,
"ui types not found in scope")
}
// get index
idx := options.Index
if idx < 0 {
idx = len(uiResults) + idx
}
// index out of range
if idx >= len(uiResults) || idx < 0 {
return UIResult{}, errors.Wrap(code.CVResultNotFoundError,
fmt.Sprintf("ui types index %d out of range", idx))
}
return uiResults[idx], nil
}
// ClosePopupsResult represents the result of recognized popup to close
type ClosePopupsResult struct {
Type string `json:"type"`
PopupArea Box `json:"popupArea"`
CloseArea Box `json:"closeArea"`
Text string `json:"text"`
}
func (c ClosePopupsResult) IsEmpty() bool {
return c.PopupArea.IsEmpty() && c.CloseArea.IsEmpty()
}
type Point struct {
X int `json:"x"` // upper left X coordinate of selected element
Y int `json:"y"` // upper left Y coordinate of selected element
}
type PointF struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
func (p PointF) IsIdentical(p2 PointF) bool {
// set the coordinate precision to 1 pixel
return math.Abs(p.X-p2.X) < 1 && math.Abs(p.Y-p2.Y) < 1
}

View File

@@ -1,258 +0,0 @@
package ai
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
)
var client = &http.Client{
Timeout: time.Second * 10,
}
type APIResponseImage struct {
Code int `json:"code"`
Message string `json:"message"`
Result CVResult `json:"result"`
}
func NewVEDEMImageService() (*vedemCVService, error) {
if err := checkEnv(); err != nil {
return nil, err
}
return &vedemCVService{}, nil
}
// vedemCVService implements IImageService interface
// actions:
//
// ocr - get ocr texts
// upload - get image uploaded url
// liveType - get live type
// popup - get popup windows
// close - get close popup
// ui - get ui position by type(s)
type vedemCVService struct{}
func (s *vedemCVService) ReadFromPath(imagePath string, opts ...option.ActionOption) (
imageResult *CVResult, err error) {
imageBuf, err := os.ReadFile(imagePath)
if err != nil {
err = errors.Wrap(code.CVRequestError,
fmt.Sprintf("read image file error: %v", err))
return
}
imageResult, err = s.ReadFromBuffer(bytes.NewBuffer(imageBuf), opts...)
return
}
func (s *vedemCVService) ReadFromBuffer(imageBuf *bytes.Buffer, opts ...option.ActionOption) (
imageResult *CVResult, err error) {
actionOptions := option.NewActionOptions(opts...)
screenshotActions := actionOptions.List()
if len(screenshotActions) == 0 {
// skip
return nil, nil
}
start := time.Now()
defer func() {
elapsed := time.Since(start).Milliseconds()
var logger *zerolog.Event
if err != nil {
logger = log.Error().Err(err)
} else {
logger = log.Debug()
if imageResult.URL != "" {
logger = logger.Str("url", imageResult.URL)
}
if imageResult.UIResult != nil {
logger = logger.Interface("uiResult", imageResult.UIResult)
}
if imageResult.ClosePopupsResult != nil {
if imageResult.ClosePopupsResult.IsEmpty() {
// set nil to reduce unnecessary summary info
imageResult.ClosePopupsResult = nil
} else {
logger = logger.Interface("closePopupsResult", imageResult.ClosePopupsResult)
}
}
}
logger = logger.Int64("elapsed(ms)", elapsed)
logger.Msg("get image data by veDEM")
}()
bodyBuf := &bytes.Buffer{}
bodyWriter := multipart.NewWriter(bodyBuf)
for _, action := range screenshotActions {
bodyWriter.WriteField("actions", action)
}
for _, uiType := range actionOptions.ScreenShotWithUITypes {
bodyWriter.WriteField("uiTypes", uiType)
}
// 使用高精度集群
bodyWriter.WriteField("ocrCluster", "highPrecision")
if actionOptions.ScreenShotWithOCRCluster != "" {
bodyWriter.WriteField("ocrCluster", actionOptions.ScreenShotWithOCRCluster)
}
bodyWriter.WriteField("timeout", fmt.Sprintf("%v", 10))
formWriter, err := bodyWriter.CreateFormFile("image", "screenshot.png")
if err != nil {
err = errors.Wrap(code.CVRequestError,
fmt.Sprintf("create form file error: %v", err))
return
}
size, err := formWriter.Write(imageBuf.Bytes())
if err != nil {
err = errors.Wrap(code.CVRequestError,
fmt.Sprintf("write form error: %v", err))
return
}
err = bodyWriter.Close()
if err != nil {
err = errors.Wrap(code.CVRequestError,
fmt.Sprintf("close body writer error: %v", err))
return
}
var req *http.Request
var resp *http.Response
// retry 3 times
for i := 1; i <= 3; i++ {
copiedBodyBuf := &bytes.Buffer{}
if _, err := copiedBodyBuf.Write(bodyBuf.Bytes()); err != nil {
log.Error().Err(err).Msg("copy screenshot buffer failed")
continue
}
req, err = http.NewRequest("POST", os.Getenv("VEDEM_IMAGE_URL"), copiedBodyBuf)
if err != nil {
err = errors.Wrap(code.CVRequestError,
fmt.Sprintf("construct request error: %v", err))
return
}
// ppe env
// req.Header.Add("x-tt-env", "ppe_vedem_algorithm")
// req.Header.Add("x-use-ppe", "1")
signToken := "UNSIGNED-PAYLOAD"
token := builtin.Sign("auth-v2", os.Getenv("VEDEM_IMAGE_AK"), os.Getenv("VEDEM_IMAGE_SK"), []byte(signToken))
req.Header.Add("Agw-Auth", token)
req.Header.Add("Agw-Auth-Content", signToken)
req.Header.Add("Content-Type", bodyWriter.FormDataContentType())
start := time.Now()
resp, err = client.Do(req)
elapsed := time.Since(start)
if err != nil {
log.Error().Err(err).
Int("imageBufSize", size).
Msgf("request veDEM OCR service error, retry %d", i)
continue
}
logID := getLogID(resp.Header)
statusCode := resp.StatusCode
if statusCode != http.StatusOK {
log.Error().
Str("X-TT-LOGID", logID).
Int("imageBufSize", size).
Int("statusCode", statusCode).
Msgf("request veDEM OCR service failed, retry %d", i)
time.Sleep(1 * time.Second)
continue
}
log.Debug().
Str("X-TT-LOGID", logID).
Int("image_bytes", size).
Int64("elapsed(ms)", elapsed.Milliseconds()).
Msg("request OCR service success")
break
}
if resp == nil {
err = code.CVServiceConnectionError
return
}
defer resp.Body.Close()
results, err := io.ReadAll(resp.Body)
if err != nil {
err = errors.Wrap(code.CVResponseError,
fmt.Sprintf("read response body error: %v", err))
return
}
if resp.StatusCode != http.StatusOK {
err = errors.Wrap(code.CVResponseError,
fmt.Sprintf("unexpected response status code: %d, results: %v",
resp.StatusCode, string(results)))
return
}
var imageResponse APIResponseImage
err = json.Unmarshal(results, &imageResponse)
if err != nil {
err = errors.Wrap(code.CVResponseError,
fmt.Sprintf("json unmarshal veDEM image response body error, response=%s", string(results)))
return
}
if imageResponse.Code != 0 {
err = errors.Wrap(code.CVResponseError,
fmt.Sprintf("unexpected response data code: %d, message: %s",
imageResponse.Code, imageResponse.Message))
return
}
imageResult = &imageResponse.Result
return imageResult, nil
}
func checkEnv() error {
vedemImageURL := os.Getenv("VEDEM_IMAGE_URL")
if vedemImageURL == "" {
return errors.Wrap(code.CVEnvMissedError, "VEDEM_IMAGE_URL missed")
}
log.Info().Str("VEDEM_IMAGE_URL", vedemImageURL).Msg("get env")
if os.Getenv("VEDEM_IMAGE_AK") == "" {
return errors.Wrap(code.CVEnvMissedError, "VEDEM_IMAGE_AK missed")
}
if os.Getenv("VEDEM_IMAGE_SK") == "" {
return errors.Wrap(code.CVEnvMissedError, "VEDEM_IMAGE_SK missed")
}
return nil
}
func getLogID(header http.Header) string {
if len(header) == 0 {
return ""
}
logID, ok := header["X-Tt-Logid"]
if !ok || len(logID) == 0 {
return ""
}
return logID[0]
}

View File

@@ -1,38 +0,0 @@
//go:build localtest
package ai
import (
"bytes"
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetImageFromBuffer(t *testing.T) {
imagePath := "/Users/debugtalk/Downloads/s1.png"
file, err := os.ReadFile(imagePath)
require.Nil(t, err)
buf := new(bytes.Buffer)
buf.Read(file)
service := NewAIService(
WithCVService(CVServiceTypeVEDEM),
)
cvResult, err := service.ReadFromBuffer(buf)
assert.Nil(t, err)
fmt.Println(fmt.Sprintf("cvResult: %v", cvResult))
}
func TestGetImageFromPath(t *testing.T) {
imagePath := "/Users/debugtalk/Downloads/s1.png"
service := NewAIService(
WithCVService(CVServiceTypeVEDEM),
)
cvResult, err := service.ReadFromPath(imagePath)
assert.Nil(t, err)
fmt.Println(fmt.Sprintf("cvResult: %v", cvResult))
}

View File

@@ -1,17 +0,0 @@
package ai
import "context"
type ILLMService interface {
Call(ctx context.Context, prompt string) (string, error)
}
func NewGPT4oLLMService() (*openaiLLMService, error) {
return &openaiLLMService{}, nil
}
type openaiLLMService struct{}
func (s openaiLLMService) Call(ctx context.Context, prompt string) (string, error) {
return "", nil
}

View File

@@ -1,527 +0,0 @@
package uixt
import (
"bufio"
"bytes"
"context"
"crypto/md5"
"embed"
"encoding/base64"
"encoding/hex"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"github.com/httprunner/funplugin/myexec"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"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/internal/json"
"github.com/httprunner/httprunner/v5/pkg/gadb"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
const (
EvalInstallerPackageName = "sogou.mobile.explorer"
InstallViaInstallerCommand = "am start -S -n sogou.mobile.explorer/.PackageInstallerActivity -d"
)
//go:embed evalite
var evalite embed.FS
func NewAndroidDevice(opts ...option.AndroidDeviceOption) (device *AndroidDevice, err error) {
androidOptions := option.NewAndroidDeviceOptions(opts...)
// get all attached android devices
adbClient, err := gadb.NewClientWith(
androidOptions.AdbServerHost, androidOptions.AdbServerPort)
if err != nil {
return nil, errors.Wrap(code.DeviceConnectionError, err.Error())
}
devices, err := adbClient.DeviceList()
if err != nil {
return nil, errors.Wrap(code.DeviceConnectionError, err.Error())
}
if len(devices) == 0 {
return nil, errors.Wrapf(code.DeviceConnectionError,
"no attached android devices")
}
// filter device by serial
var gadbDevice *gadb.Device
if androidOptions.SerialNumber == "" {
if len(devices) > 1 {
return nil, errors.Wrap(code.DeviceConnectionError,
"more than one device connected, please specify the serial")
}
gadbDevice = devices[0]
androidOptions.SerialNumber = gadbDevice.Serial()
log.Warn().Str("serial", androidOptions.SerialNumber).
Msg("android SerialNumber is not specified, select the attached one")
} else {
for _, d := range devices {
if d.Serial() == androidOptions.SerialNumber {
gadbDevice = d
break
}
}
if gadbDevice == nil {
return nil, errors.Wrapf(code.DeviceConnectionError,
"android device %s not attached", androidOptions.SerialNumber)
}
}
device = &AndroidDevice{
Device: gadbDevice,
Options: androidOptions,
Logcat: NewAdbLogcat(androidOptions.SerialNumber),
}
log.Info().Str("serial", device.Options.SerialNumber).Msg("init android device")
// setup device
if err := device.Setup(); err != nil {
return nil, errors.Wrap(err, "setup android device failed")
}
return device, nil
}
type AndroidDevice struct {
*gadb.Device
Options *option.AndroidDeviceOptions
Logcat *AdbLogcat
}
func (dev *AndroidDevice) Setup() error {
dev.Device.RunShellCommand("ime", "enable", option.UnicodeImePackageName)
dev.Device.RunShellCommand("rm", "-r", config.GetConfig().DeviceActionLogFilePath)
// setup evalite
evalToolRaw, err := evalite.ReadFile("evalite")
if err != nil {
return errors.Wrap(code.LoadFileError, err.Error())
}
err = dev.Device.Push(bytes.NewReader(evalToolRaw), "/data/local/tmp/evalite", time.Now())
if err != nil {
return errors.Wrap(code.DeviceShellExecError, err.Error())
}
return nil
}
func (dev *AndroidDevice) Teardown() error {
return nil
}
func (dev *AndroidDevice) UUID() string {
return dev.Options.SerialNumber
}
func (dev *AndroidDevice) LogEnabled() bool {
return dev.Options.LogOn
}
func (dev *AndroidDevice) NewDriver() (driver IDriver, err error) {
if dev.Options.UIA2 || dev.Options.LogOn {
driver, err = NewUIA2Driver(dev)
} else {
driver, err = NewADBDriver(dev)
}
if err != nil {
return nil, errors.Wrap(err, "failed to init UIA driver")
}
if dev.Options.LogOn {
err = driver.StartCaptureLog("hrp_adb_log")
if err != nil {
return nil, err
}
}
return driver, nil
}
func (dev *AndroidDevice) Install(apkPath string, opts ...option.InstallOption) error {
installOpts := option.NewInstallOptions(opts...)
brand, err := dev.Device.Brand()
if err != nil {
return err
}
args := []string{}
if installOpts.Reinstall {
args = append(args, "-r")
}
if installOpts.GrantPermission {
args = append(args, "-g")
}
if installOpts.Downgrade {
args = append(args, "-d")
}
switch strings.ToLower(brand) {
case "vivo":
return dev.installVivoSilent(apkPath, args...)
case "oppo", "realme", "oneplus":
if dev.Device.IsPackageInstalled(EvalInstallerPackageName) {
return dev.installViaInstaller(apkPath, args...)
}
log.Warn().Msg("oppo not install eval installer")
return dev.installCommon(apkPath, args...)
default:
return dev.installCommon(apkPath, args...)
}
}
func (dev *AndroidDevice) installVivoSilent(apkPath string, args ...string) error {
currentTime := builtin.GetCurrentDay()
md5HashInBytes := md5.Sum([]byte(currentTime))
verifyCode := hex.EncodeToString(md5HashInBytes[:])
verifyCode = base64.StdEncoding.EncodeToString([]byte(verifyCode))
verifyCode = verifyCode[:8]
verifyCode = "-V" + verifyCode
args = append([]string{verifyCode}, args...)
_, err := dev.Device.InstallAPK(apkPath, args...)
return err
}
func (dev *AndroidDevice) installViaInstaller(apkPath string, args ...string) error {
appRemotePath := "/data/local/tmp/" + strconv.FormatInt(time.Now().UnixMilli(), 10) + ".apk"
err := dev.Device.PushFile(apkPath, appRemotePath, time.Now())
if err != nil {
return err
}
done := make(chan error)
defer func() {
close(done)
}()
logcat := NewAdbLogcatWithCallback(dev.Device.Serial(), func(line string) {
re := regexp.MustCompile(`\{.*?}`)
match := re.FindString(line)
if match == "" {
return
}
var result InstallResult
err := json.Unmarshal([]byte(match), &result)
if err != nil {
log.Warn().Msg("parse Install msg line error: " + match)
return
}
if result.Result == 0 {
// 安装成功
done <- nil
} else {
done <- errors.New(match)
}
})
err = logcat.CatchLogcat("PackageInstallerCallback")
if err != nil {
return err
}
defer func() {
_ = logcat.Stop()
}()
// 需要监听是否完成安装
command := strings.Split(InstallViaInstallerCommand, " ")
args = append(command, appRemotePath)
_, err = dev.Device.RunShellCommand("am", args[1:]...)
if err != nil {
return err
}
// 等待安装完成或超时
timeout := 3 * time.Minute
select {
case err := <-done:
return err
case <-time.After(timeout):
return fmt.Errorf("installation timed out after %v", timeout)
}
}
type InstallResult struct {
Result int `json:"result"`
ErrorCode int `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
}
func (dev *AndroidDevice) installCommon(apkPath string, args ...string) error {
_, err := dev.Device.InstallAPK(apkPath, args...)
return err
}
func (dev *AndroidDevice) Uninstall(packageName string) error {
_, err := dev.Device.Uninstall(packageName)
return err
}
func (dev *AndroidDevice) GetCurrentWindow() (windowInfo types.WindowInfo, err error) {
// adb shell dumpsys window | grep -E 'mCurrentFocus|mFocusedApp'
output, err := dev.Device.RunShellCommand("dumpsys", "window", "|", "grep", "-E", "'mCurrentFocus|mFocusedApp'")
if err != nil {
return types.WindowInfo{}, errors.Wrap(err, "get current window failed")
}
// mCurrentFocus=Window{a33bc55 u0 com.miui.home/com.miui.home.launcher.Launcher}
reFocus := regexp.MustCompile(`mCurrentFocus=Window{.*? (\S+)/(\S+)}`)
matches := reFocus.FindStringSubmatch(output)
if len(matches) == 3 {
windowInfo = types.WindowInfo{
PackageName: matches[1],
Activity: matches[2],
}
return windowInfo, nil
}
// mFocusedApp=ActivityRecord{2db504f u0 com.miui.home/.launcher.Launcher t2}
reApp := regexp.MustCompile(`mFocusedApp=ActivityRecord{.*? (\S+)/(\S+?)\s`)
matches = reApp.FindStringSubmatch(output)
if len(matches) == 3 {
windowInfo = types.WindowInfo{
PackageName: matches[1],
Activity: matches[2],
}
return windowInfo, nil
}
// adb shell dumpsys activity activities | grep mResumedActivity
output, err = dev.Device.RunShellCommand("dumpsys", "activity", "activities", "|", "grep", "mResumedActivity")
if err != nil {
return types.WindowInfo{}, errors.Wrap(err, "get current activity failed")
}
// mResumedActivity: ActivityRecord{2db504f u0 com.miui.home/.launcher.Launcher t2}
reActivity := regexp.MustCompile(`mResumedActivity: ActivityRecord{.*? (\S+)/(\S+?)\s`)
matches = reActivity.FindStringSubmatch(output)
if len(matches) == 3 {
windowInfo = types.WindowInfo{
PackageName: matches[1],
Activity: matches[2],
}
return windowInfo, nil
}
return types.WindowInfo{}, errors.New("failed to extract current window")
}
func (dev *AndroidDevice) GetPackageInfo(packageName string) (types.AppInfo, error) {
appInfo := types.AppInfo{
Name: packageName,
}
// get package version
appVersion, err := dev.getPackageVersion(packageName)
if err == nil {
appInfo.AppBaseInfo.VersionName = appVersion
} else {
log.Warn().Msg("failed to get package version")
return appInfo, errors.Wrap(code.DeviceAppNotInstalled, err.Error())
}
// get package path
packagePath, err := dev.getPackagePath(packageName)
if err == nil {
appInfo.AppBaseInfo.AppPath = packagePath
} else {
log.Warn().Msg("failed to get package path")
return appInfo, errors.Wrap(code.DeviceAppNotInstalled, err.Error())
}
// get package md5
packageMD5, err := dev.getPackageMD5(packagePath)
if err == nil {
appInfo.AppBaseInfo.AppMD5 = packageMD5
} else {
log.Warn().Msg("failed to get package md5")
return appInfo, errors.Wrap(code.DeviceAppNotInstalled, err.Error())
}
log.Info().Interface("appInfo", appInfo).Msg("get package info")
return appInfo, nil
}
func (dev *AndroidDevice) ScreenShot() (*bytes.Buffer, error) {
raw, err := dev.Device.ScreenCap()
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"adb screencap failed %v", err)
}
return bytes.NewBuffer(raw), nil
}
func (dev *AndroidDevice) GetAppInfo(packageName string) (app types.AppInfo, err error) {
packageInfo, err := dev.RunShellCommand(
"CLASSPATH=/data/local/tmp/evalite", "app_process", "/",
"com.bytedance.iesqa.eval_process.PackageService", packageName, "2>/dev/null")
if packageInfo == "" {
return app, nil
}
if err != nil {
return app, err
}
err = json.Unmarshal([]byte(strings.TrimSpace(packageInfo)), &app)
if err != nil {
log.Error().Err(err).Str("packageInfo", packageInfo)
}
return
}
func (dev *AndroidDevice) getPackageVersion(packageName string) (string, error) {
output, err := dev.Device.RunShellCommand("dumpsys", "package", packageName, "|", "grep", "versionName")
if err != nil {
return "", errors.Wrap(err, "get package version failed")
}
appVersion := ""
re := regexp.MustCompile(`versionName=(.+)`)
matches := re.FindStringSubmatch(output)
if len(matches) > 1 {
appVersion = matches[1]
return appVersion, nil
}
return "", errors.New("failed to get package version")
}
func (dev *AndroidDevice) getPackagePath(packageName string) (string, error) {
output, err := dev.Device.RunShellCommand("pm", "path", packageName)
if err != nil {
return "", errors.Wrap(err, "get package path failed")
}
re := regexp.MustCompile(`package:(.+)`)
matches := re.FindStringSubmatch(output)
if len(matches) > 1 {
return matches[1], nil
}
return "", errors.New("failed to get package path")
}
func (dev *AndroidDevice) getPackageMD5(packagePath string) (string, error) {
output, err := dev.Device.RunShellCommand("md5sum", packagePath)
if err != nil {
return "", errors.Wrap(err, "get package md5 failed")
}
matches := strings.Split(output, " ")
if len(matches) > 1 {
return matches[0], nil
}
return "", errors.New("failed to get package md5")
}
type LineCallback func(string)
type AdbLogcat struct {
serial string
// logBuffer *bytes.Buffer
errs []error
stopping chan struct{}
done chan struct{}
cmd *exec.Cmd
callback LineCallback
logs []string
}
func NewAdbLogcatWithCallback(serial string, callback LineCallback) *AdbLogcat {
return &AdbLogcat{
serial: serial,
// logBuffer: new(bytes.Buffer),
stopping: make(chan struct{}),
done: make(chan struct{}),
callback: callback,
logs: make([]string, 0),
}
}
func NewAdbLogcat(serial string) *AdbLogcat {
return &AdbLogcat{
serial: serial,
// logBuffer: new(bytes.Buffer),
stopping: make(chan struct{}),
done: make(chan struct{}),
logs: make([]string, 0),
}
}
// CatchLogcatContext starts logcat with timeout context
func (l *AdbLogcat) CatchLogcatContext(timeoutCtx context.Context) (err error) {
if err = l.CatchLogcat(""); err != nil {
return
}
go func() {
select {
case <-timeoutCtx.Done():
_ = l.Stop()
case <-l.stopping:
}
}()
return
}
func (l *AdbLogcat) Stop() error {
select {
case <-l.stopping:
default:
close(l.stopping)
<-l.done
close(l.done)
}
return l.Errors()
}
func (l *AdbLogcat) Errors() (err error) {
for _, e := range l.errs {
if err != nil {
err = fmt.Errorf("%v |[DeviceLogcatErr] %v", err, e)
} else {
err = fmt.Errorf("[DeviceLogcatErr] %v", e)
}
}
return
}
func (l *AdbLogcat) CatchLogcat(filter string) (err error) {
if l.cmd != nil {
log.Warn().Msg("logcat already start")
return nil
}
// FIXME: replace with gadb shell command
// clear logcat
if err = myexec.RunCommand("adb", "-s", l.serial, "shell", "logcat", "-c"); err != nil {
return
}
args := []string{"-s", l.serial, "logcat", "--format", "time"}
if filter != "" {
args = append(args, "-s", filter)
}
// start logcat
l.cmd = myexec.Command("adb", args...)
// l.cmd.Stderr = l.logBuffer
// l.cmd.Stdout = l.logBuffer
reader, err := l.cmd.StdoutPipe()
if err != nil {
return err
}
if err = l.cmd.Start(); err != nil {
return
}
go func() {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
if l.callback != nil {
l.callback(line) // Process each line with callback
} else {
l.logs = append(l.logs, line) // Store line if no callback
}
}
}()
go func() {
<-l.stopping
if e := reader.Close(); e != nil {
log.Error().Err(e).Msg("close logcat reader failed")
}
if e := myexec.KillProcessesByGpid(l.cmd); e != nil {
log.Error().Err(e).Msg("kill logcat process failed")
}
l.done <- struct{}{}
}()
return
}

View File

@@ -1,908 +0,0 @@
package uixt
import (
"bufio"
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/internal/utf7"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
func NewADBDriver(device *AndroidDevice) (*ADBDriver, error) {
log.Info().Interface("device", device).Msg("init android adb driver")
driver := &ADBDriver{
Device: device,
Session: NewDriverSession(),
}
// setup driver
if err := driver.Setup(); err != nil {
return nil, err
}
return driver, nil
}
type ADBDriver struct {
Device *AndroidDevice
Session *DriverSession
// cache to avoid repeated query
windowSize types.Size
}
func (ad *ADBDriver) runShellCommand(cmd string, args ...string) (output string, err error) {
driverResult := &DriverRequests{
RequestMethod: "adb",
RequestUrl: cmd,
RequestBody: strings.Join(args, " "),
RequestTime: time.Now(),
}
defer func() {
driverResult.ResponseDuration = time.Since(driverResult.RequestTime).Milliseconds()
if err != nil {
driverResult.Success = false
driverResult.Error = err.Error()
} else {
driverResult.Success = true
}
ad.Session.addRequestResult(driverResult)
}()
// adb shell screencap -p
if cmd == "screencap" {
resp, err := ad.Device.ScreenCap()
if err == nil {
driverResult.ResponseBody = "OMITTED"
return string(resp), nil
}
return "", errors.Wrap(err, "adb screencap failed")
}
output, err = ad.Device.RunShellCommand(cmd, args...)
driverResult.ResponseBody = strings.TrimSpace(output)
return output, err
}
func (ad *ADBDriver) InitSession(capabilities option.Capabilities) error {
log.Warn().Msg("InitSession not implemented in ADBDriver")
return nil
}
func (ad *ADBDriver) DeleteSession() error {
log.Warn().Msg("DeleteSession not implemented in ADBDriver")
return nil
}
func (ad *ADBDriver) Status() (deviceStatus types.DeviceStatus, err error) {
log.Warn().Msg("Status not implemented in ADBDriver")
return
}
func (ad *ADBDriver) GetDevice() IDevice {
return ad.Device
}
func (ad *ADBDriver) DeviceInfo() (deviceInfo types.DeviceInfo, err error) {
log.Warn().Msg("DeviceInfo not implemented in ADBDriver")
return
}
func (ad *ADBDriver) BatteryInfo() (batteryInfo types.BatteryInfo, err error) {
log.Warn().Msg("BatteryInfo not implemented in ADBDriver")
return
}
func (ad *ADBDriver) getWindowSize() (size types.Size, err error) {
// adb shell wm size
output, err := ad.runShellCommand("wm", "size")
if err != nil {
return size, errors.Wrap(err, "get window size failed by adb shell")
}
// output may contain both Physical and Override size, use Override if existed
// Physical size: 1080x2340
// Override size: 1080x2220
matchedSizeType := "Physical"
if strings.Contains(output, "Override") {
matchedSizeType = "Override"
}
var resolution string
sizeList := strings.Split(output, "\n")
log.Trace().Msgf("window size: %v", sizeList)
for _, size := range sizeList {
if strings.Contains(size, matchedSizeType) {
resolution = strings.Split(size, ": ")[1]
// 1080x2340
ss := strings.Split(resolution, "x")
width, _ := strconv.Atoi(ss[0])
height, _ := strconv.Atoi(ss[1])
return types.Size{Width: width, Height: height}, nil
}
}
err = errors.New("physical window size not found by adb")
return
}
func (ad *ADBDriver) WindowSize() (size types.Size, err error) {
if !ad.windowSize.IsNil() {
// use cached window size
return ad.windowSize, nil
}
size, err = ad.getWindowSize()
if err != nil {
return
}
orientation, err2 := ad.Orientation()
if err2 != nil {
// Notice: do not return err if get window orientation failed
orientation = types.OrientationPortrait
log.Warn().Err(err2).Msgf(
"get window orientation failed, use default %s", orientation)
}
if orientation != types.OrientationPortrait {
size.Width, size.Height = size.Height, size.Width
}
ad.windowSize = size // cache window size
return size, nil
}
// Back simulates a short press on the BACK button.
func (ad *ADBDriver) Back() (err error) {
// adb shell input keyevent 4
_, err = ad.runShellCommand("input", "keyevent", fmt.Sprintf("%d", KCBack))
if err != nil {
return errors.Wrap(err, "press back failed")
}
return nil
}
func (ad *ADBDriver) Orientation() (orientation types.Orientation, err error) {
output, err := ad.runShellCommand("dumpsys", "input", "|", "grep", "'SurfaceOrientation'")
if err != nil {
return
}
re := regexp.MustCompile(`SurfaceOrientation: (\d)`)
matches := re.FindStringSubmatch(output)
if len(matches) > 1 { // 确保找到了匹配项
if matches[1] == "0" || matches[1] == "2" {
return types.OrientationPortrait, nil
} else if matches[1] == "1" || matches[1] == "3" {
return types.OrientationLandscapeLeft, nil
}
}
err = fmt.Errorf("not found SurfaceOrientation value")
return
}
func (ad *ADBDriver) Home() (err error) {
return ad.PressKeyCode(KCHome, KMEmpty)
}
func (ad *ADBDriver) Unlock() (err error) {
// Notice: brighten should be executed before unlock
// brighten android device screen
if err := ad.PressKeyCode(KCWakeup, KMEmpty); err != nil {
log.Error().Err(err).Msg("brighten android device screen failed")
}
// unlock android device screen
if err := ad.PressKeyCode(KCMenu, KMEmpty); err != nil {
log.Error().Err(err).Msg("press menu key to unlock screen failed")
}
// swipe up to unlock
return ad.Swipe(500, 1500, 500, 500)
}
func (ad *ADBDriver) Backspace(count int, opts ...option.ActionOption) (err error) {
if count == 0 {
return nil
}
if count == 1 {
return ad.PressKeyCode(KCDel, KMEmpty)
}
keyArray := make([]KeyCode, count)
for i := range keyArray {
keyArray[i] = KCDel
}
return ad.combinationKey(keyArray)
}
func (ad *ADBDriver) combinationKey(keyCodes []KeyCode) (err error) {
if len(keyCodes) == 1 {
return ad.PressKeyCode(keyCodes[0], KMEmpty)
}
strKeyCodes := make([]string, len(keyCodes))
for i, keycode := range keyCodes {
strKeyCodes[i] = fmt.Sprintf("%d", keycode)
}
_, err = ad.runShellCommand(
"input", append([]string{"keycombination"}, strKeyCodes...)...)
return
}
func (ad *ADBDriver) PressKeyCode(keyCode KeyCode, metaState KeyMeta) (err error) {
// adb shell input keyevent [--longpress] KEYCODE [METASTATE]
if metaState != KMEmpty {
// press key with metastate, e.g. KMShiftOn/KMCtrlOn
_, err = ad.runShellCommand(
"input", "keyevent", "--longpress",
fmt.Sprintf("%d", keyCode),
fmt.Sprintf("%d", metaState))
} else {
_, err = ad.runShellCommand(
"input", "keyevent",
fmt.Sprintf("%d", keyCode))
}
return
}
func (ad *ADBDriver) AppLaunch(packageName string) (err error) {
// 不指定 Activity 名称启动(启动主 Activity
// adb shell monkey -p <packagename> -c android.intent.category.LAUNCHER 1
sOutput, err := ad.runShellCommand(
"monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1",
)
if err != nil {
return errors.Wrap(code.MobileUILaunchAppError,
fmt.Sprintf("monkey launch failed: %v", err))
}
if strings.Contains(sOutput, "monkey aborted") {
return errors.Wrap(code.MobileUILaunchAppError,
fmt.Sprintf("monkey aborted: %s", strings.TrimSpace(sOutput)))
}
return nil
}
func (ad *ADBDriver) AppTerminate(packageName string) (successful bool, err error) {
// 强制停止应用,停止 <packagename> 相关的进程
// adb shell am force-stop <packagename>
_, err = ad.runShellCommand("am", "force-stop", packageName)
if err != nil {
return false, errors.Wrap(err, "force-stop app failed")
}
return true, nil
}
func (ad *ADBDriver) TapXY(x, y float64, opts ...option.ActionOption) error {
absX, absY, err := convertToAbsolutePoint(ad, x, y)
if err != nil {
return err
}
return ad.TapAbsXY(absX, absY, opts...)
}
func (ad *ADBDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
actionOptions := option.NewActionOptions(opts...)
x, y = actionOptions.ApplyOffset(x, y)
// adb shell input tap x y
xStr := fmt.Sprintf("%.1f", x)
yStr := fmt.Sprintf("%.1f", y)
_, err := ad.runShellCommand(
"input", "tap", xStr, yStr)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("tap <%s, %s> failed", xStr, yStr))
}
return nil
}
func (ad *ADBDriver) DoubleTap(x, y float64, opts ...option.ActionOption) error {
var err error
actionOptions := option.NewActionOptions(opts...)
x, y, err = convertToAbsolutePoint(ad, x, y)
if err != nil {
return err
}
x, y = actionOptions.ApplyOffset(x, y)
// adb shell input tap x y
xStr := fmt.Sprintf("%.1f", x)
yStr := fmt.Sprintf("%.1f", y)
_, err = ad.runShellCommand(
"input", "tap", xStr, yStr)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("tap <%s, %s> failed", xStr, yStr))
}
time.Sleep(time.Duration(100) * time.Millisecond)
_, err = ad.runShellCommand(
"input", "tap", xStr, yStr)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("tap <%s, %s> failed", xStr, yStr))
}
return nil
}
func (ad *ADBDriver) TouchAndHold(x, y float64, opts ...option.ActionOption) (err error) {
actionOptions := option.NewActionOptions(opts...)
x, y = actionOptions.ApplyOffset(x, y)
duration := 1000.0
if actionOptions.Duration > 0 {
duration = actionOptions.Duration * 1000
}
// adb shell input swipe fromX fromY toX toY
_, err = ad.runShellCommand(
"input", "swipe",
fmt.Sprintf("%.1f", x), fmt.Sprintf("%.1f", y),
fmt.Sprintf("%.1f", x), fmt.Sprintf("%.1f", y),
fmt.Sprintf("%d", int(duration)),
)
if err != nil {
return errors.Wrap(err, "long press failed")
}
return nil
}
func (ad *ADBDriver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionOption) (err error) {
actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(ad, fromX, fromY, toX, toY)
if err != nil {
return err
}
duration := 200.0
if actionOptions.Duration > 0 {
duration = actionOptions.Duration * 1000
}
command := "swipe"
if actionOptions.PressDuration > 0 {
command = "draganddrop"
}
// adb shell input swipe fromX fromY toX toY
_, err = ad.runShellCommand(
"input", command,
fmt.Sprintf("%.1f", fromX), fmt.Sprintf("%.1f", fromY),
fmt.Sprintf("%.1f", toX), fmt.Sprintf("%.1f", toY),
fmt.Sprintf("%d", int(duration)),
)
if err != nil {
return errors.Wrap(err, "adb drag failed")
}
return nil
}
func (ad *ADBDriver) Swipe(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
var err error
fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(ad, fromX, fromY, toX, toY)
if err != nil {
return err
}
// adb shell input swipe fromX fromY toX toY
_, err = ad.runShellCommand(
"input", "swipe",
fmt.Sprintf("%.1f", fromX), fmt.Sprintf("%.1f", fromY),
fmt.Sprintf("%.1f", toX), fmt.Sprintf("%.1f", toY),
)
if err != nil {
return errors.Wrap(err, "adb swipe failed")
}
return nil
}
func (ad *ADBDriver) ForceTouch(x, y int, pressure float64, second ...float64) error {
return ad.ForceTouchFloat(float64(x), float64(y), pressure, second...)
}
func (ad *ADBDriver) ForceTouchFloat(x, y, pressure float64, second ...float64) (err error) {
log.Warn().Msg("ForceTouchFloat not implemented in ADBDriver")
return
}
func (ad *ADBDriver) Input(text string, opts ...option.ActionOption) error {
err := ad.SendUnicodeKeys(text, opts...)
if err == nil {
return nil
}
// adb shell input text <text>
return ad.input(text, opts...)
}
func (ad *ADBDriver) input(text string, _ ...option.ActionOption) error {
_, err := ad.runShellCommand("input", "text", text)
if err != nil {
return errors.Wrap(err, "send keys failed")
}
return nil
}
func (ad *ADBDriver) SendUnicodeKeys(text string, opts ...option.ActionOption) (err error) {
// If the Unicode IME is not installed, fall back to the old interface.
// There might be differences in the tracking schemes across different phones, and it is pending further verification.
// In release version: without the Unicode IME installed, the test cannot execute.
if !ad.IsUnicodeIMEInstalled() {
return fmt.Errorf("appium unicode ime not installed")
}
currentIme, err := ad.GetIme()
if err != nil {
return
}
if currentIme != option.UnicodeImePackageName {
defer func() {
_ = ad.SetIme(currentIme)
}()
err = ad.SetIme(option.UnicodeImePackageName)
if err != nil {
log.Warn().Err(err).Msgf("set Unicode Ime failed")
return
}
}
encodedStr, err := utf7.Encoding.NewEncoder().String(text)
if err != nil {
log.Warn().Err(err).Msgf("encode text with modified utf7 failed")
return
}
err = ad.input("\""+strings.ReplaceAll(encodedStr, "\"", "\\\"")+"\"", opts...)
return
}
func (ad *ADBDriver) IsAdbKeyBoardInstalled() bool {
output, err := ad.runShellCommand("ime", "list", "-a")
if err != nil {
return false
}
return strings.Contains(output, option.AdbKeyBoardPackageName)
}
func (ad *ADBDriver) IsUnicodeIMEInstalled() bool {
output, err := ad.runShellCommand("ime", "list", "-s")
if err != nil {
return false
}
return strings.Contains(output, option.UnicodeImePackageName)
}
func (ad *ADBDriver) ListIme() []string {
output, err := ad.runShellCommand("ime", "list", "-s")
if err != nil {
return []string{}
}
return strings.Split(output, "\n")
}
func (ad *ADBDriver) SendKeysByAdbKeyBoard(text string) (err error) {
defer func() {
// Reset to default, don't care which keyboard was chosen before switch:
if _, resetErr := ad.runShellCommand("ime", "reset"); resetErr != nil {
log.Error().Err(err).Msg("failed to reset ime")
}
}()
// Enable ADBKeyBoard from adb
if _, err = ad.runShellCommand("ime", "enable", option.AdbKeyBoardPackageName); err != nil {
log.Error().Err(err).Msg("failed to enable adbKeyBoard")
return
}
// Switch to ADBKeyBoard from adb
if _, err = ad.runShellCommand("ime", "set", option.AdbKeyBoardPackageName); err != nil {
log.Error().Err(err).Msg("failed to set adbKeyBoard")
return
}
time.Sleep(time.Second)
// input Quoted text
text = strings.ReplaceAll(text, " ", "\\ ")
if _, err = ad.runShellCommand("am", "broadcast", "-a", "ADB_INPUT_TEXT", "--es", "msg", text); err != nil {
log.Error().Err(err).Msg("failed to input by adbKeyBoard")
return
}
if _, err = ad.runShellCommand("input", "keyevent", fmt.Sprintf("%d", KCEnter)); err != nil {
log.Error().Err(err).Msg("failed to input keyevent enter")
return
}
time.Sleep(time.Second)
return
}
func (ad *ADBDriver) AppClear(packageName string) error {
if _, err := ad.runShellCommand("pm", "clear", packageName); err != nil {
log.Error().Str("packageName", packageName).Err(err).Msg("failed to clear package cache")
return err
}
return nil
}
func (ad *ADBDriver) Rotation() (rotation types.Rotation, err error) {
log.Warn().Msg("Rotation not implemented in ADBDriver")
return
}
func (ad *ADBDriver) SetRotation(rotation types.Rotation) (err error) {
log.Warn().Msg("SetRotation not implemented in ADBDriver")
return
}
func (ad *ADBDriver) ScreenShot(opts ...option.ActionOption) (raw *bytes.Buffer, err error) {
resp, err := ad.Device.ScreenCap()
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"adb screencap failed %v", err)
}
raw = bytes.NewBuffer(resp)
actionOptions := option.NewActionOptions(opts...)
if actionOptions.ScreenShotFileName != "" {
// save screenshot to file
path, err := saveScreenShot(raw, actionOptions.ScreenShotFileName)
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"save screenshot file failed %v", err)
}
log.Info().Str("path", path).Msg("screenshot saved")
}
return raw, nil
}
func (ad *ADBDriver) TapByHierarchy(text string, opts ...option.ActionOption) error {
sourceTree, err := ad.sourceTree()
if err != nil {
return err
}
return ad.tapByTextUsingHierarchy(sourceTree, text, opts...)
}
func (ad *ADBDriver) Source(srcOpt ...option.SourceOption) (source string, err error) {
_, err = ad.runShellCommand("rm", "-rf", "/sdcard/window_dump.xml")
if err != nil {
return
}
// 高版本报错 ERROR: null root node returned by UiTestAutomationBridge.
_, err = ad.runShellCommand("uiautomator", "dump")
if err != nil {
return
}
source, err = ad.runShellCommand("cat", "/sdcard/window_dump.xml")
if err != nil {
return
}
return
}
func (ad *ADBDriver) sourceTree(srcOpt ...option.SourceOption) (sourceTree *Hierarchy, err error) {
source, err := ad.Source(srcOpt...)
if err != nil {
return
}
sourceTree = new(Hierarchy)
err = xml.Unmarshal([]byte(source), sourceTree)
if err != nil {
return
}
return
}
func (ad *ADBDriver) tapByTextUsingHierarchy(hierarchy *Hierarchy, text string, opts ...option.ActionOption) error {
bounds := ad.searchNodes(hierarchy.Layout, text, opts...)
actionOptions := option.NewActionOptions(opts...)
if len(bounds) == 0 {
if actionOptions.IgnoreNotFoundError {
log.Info().Msg("not found element by text " + text)
return nil
}
return errors.New("not found element by text " + text)
}
for _, bound := range bounds {
width, height := bound.Center()
err := ad.TapXY(width, height, opts...)
if err != nil {
return err
}
}
return nil
}
func (ad *ADBDriver) searchNodes(nodes []Layout, text string, opts ...option.ActionOption) []Bounds {
actionOptions := option.NewActionOptions(opts...)
var results []Bounds
for _, node := range nodes {
result := ad.searchNodes(node.Layout, text, opts...)
results = append(results, result...)
if actionOptions.Regex {
// regex on, check if match regex
if !regexp.MustCompile(text).MatchString(node.Text) {
continue
}
} else {
// regex off, check if match exactly
if node.Text != text {
ad.searchNodes(node.Layout, text, opts...)
continue
}
}
if node.Bounds != nil {
results = append(results, *node.Bounds)
}
}
return results
}
func (ad *ADBDriver) StartCaptureLog(identifier ...string) (err error) {
log.Info().Msg("start adb log recording")
// start logcat
err = ad.Device.Logcat.CatchLogcat("iesqaMonitor:V")
if err != nil {
err = errors.Wrap(code.DeviceCaptureLogError,
fmt.Sprintf("start adb log recording failed: %v", err))
return err
}
return nil
}
func (ad *ADBDriver) StopCaptureLog() (result interface{}, err error) {
defer func() {
log.Info().Msg("stop adb log recording")
err = ad.Device.Logcat.Stop()
if err != nil {
log.Error().Err(err).Msg("failed to get adb log recording")
}
}()
if err != nil {
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 >>>")
logFilePathPrefix := fmt.Sprintf("%v/data", config.GetConfig().ActionLogFilePath)
files := []string{}
ad.Device.RunShellCommand("pull", config.GetConfig().DeviceActionLogFilePath, config.GetConfig().ActionLogFilePath)
err = filepath.Walk(config.GetConfig().ActionLogFilePath, func(path string, info fs.FileInfo, err error) error {
// 只是需要日志文件
if ok := strings.Contains(path, logFilePathPrefix); ok {
files = append(files, path)
}
return nil
})
// 先保持原有状态码不变这里不return error
if err != nil {
log.Error().Err(err).Msg("read log file fail")
return pointRes, nil
}
if len(files) != 1 {
log.Error().Err(err).Msg("log file count error")
return pointRes, nil
}
reader, err := os.Open(files[0])
if err != nil {
log.Info().Msg("open File error")
return pointRes, nil
}
defer func() {
_ = reader.Close()
}()
var lines []string // 创建一个空的字符串数组来存储文件的每一行
// 使用 bufio.NewScanner 读取文件
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
lines = append(lines, scanner.Text()) // 将每行文本添加到字符串数组
}
if err := scanner.Err(); err != nil {
return pointRes, nil
}
pointRes = ConvertPoints(lines)
}
return pointRes, nil
}
func (ad *ADBDriver) GetSession() *DriverSession {
return ad.Session
}
func (ad *ADBDriver) ForegroundInfo() (app types.AppInfo, err error) {
packageInfo, err := ad.runShellCommand(
"CLASSPATH=/data/local/tmp/evalite", "app_process", "/",
"com.bytedance.iesqa.eval_process.PackageService", "2>/dev/null")
if err != nil {
return app, err
}
err = json.Unmarshal([]byte(strings.TrimSpace(packageInfo)), &app)
if err != nil {
log.Error().Err(err).Str("packageInfo", packageInfo).Msg("get foreground app failed")
}
return
}
func (ad *ADBDriver) SetIme(imeRegx string) error {
imeList := ad.ListIme()
ime := ""
for _, imeName := range imeList {
if regexp.MustCompile(imeRegx).MatchString(imeName) {
ime = imeName
break
}
}
if ime == "" {
return fmt.Errorf("failed to set ime by %s, ime list: %v", imeRegx, imeList)
}
brand, _ := ad.Device.Brand()
packageName := strings.Split(ime, "/")[0]
res, err := ad.runShellCommand("ime", "set", ime)
log.Info().Str("funcName", "SetIme").Interface("ime", ime).
Interface("output", res).Msg("set ime")
if err != nil {
return err
}
if strings.ToLower(brand) == "oppo" {
time.Sleep(1 * time.Second)
pid, _ := ad.runShellCommand("pidof", packageName)
if strings.TrimSpace(pid) == "" {
appInfo, err := ad.ForegroundInfo()
_ = ad.AppLaunch(packageName)
if err == nil && packageName != option.UnicodeImePackageName {
time.Sleep(10 * time.Second)
nextAppInfo, err := ad.ForegroundInfo()
log.Info().Str("beforeFocusedPackage", appInfo.PackageName).Str("afterFocusedPackage", nextAppInfo.PackageName).Msg("")
if err == nil && nextAppInfo.PackageName != appInfo.PackageName {
_ = ad.PressKeyCode(KCBack, KMEmpty)
}
}
}
}
// even if the shell command has returned,
// as there might be a situation where the input method has not been completely switched yet
// Listen to the following message.
// InputMethodManagerService: onServiceConnected, name:ComponentInfo{io.appium.settings/io.appium.settings.UnicodeIME}, token:android.os.Binder@44f825
// But there is no such log on Vivo.
time.Sleep(3 * time.Second)
return nil
}
func (ad *ADBDriver) GetIme() (ime string, err error) {
currentIme, err := ad.runShellCommand("settings", "get", "secure", "default_input_method")
if err != nil {
log.Warn().Err(err).Msgf("get default ime failed")
return
}
currentIme = strings.TrimSpace(currentIme)
return currentIme, nil
}
func (ad *ADBDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) {
timestamp := time.Now().Format("20060102_150405") + fmt.Sprintf("_%03d", time.Now().UnixNano()/1e6%1000)
fileName := filepath.Join(config.GetConfig().ScreenShotsPath, fmt.Sprintf("%s.mp4", timestamp))
file, err := os.Create(fileName)
if err != nil {
log.Error().Err(err)
return "", err
}
defer func() {
_ = file.Close()
}()
// scrcpy -s 7d21bb91 --record=file.mp4 -N
cmd := exec.Command(
"scrcpy",
"-s", ad.Device.Serial(),
fmt.Sprintf("--record=%s", fileName),
"-N",
)
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
// 启动命令
if err := cmd.Start(); err != nil {
log.Error().Err(err)
return "", err
}
timer := time.After(duration)
done := make(chan error)
go func() {
// 等待 ffmpeg 命令执行完毕
done <- cmd.Wait()
}()
select {
case <-timer:
// 超时,停止 scrcpy 进程
if err := cmd.Process.Signal(syscall.SIGINT); err != nil {
log.Error().Err(err)
}
case err := <-done:
// ffmpeg 正常结束
if err != nil {
log.Error().Err(err)
return "", err
}
}
return filepath.Abs(fileName)
}
func (ad *ADBDriver) Setup() error {
log.Warn().Msg("Setup not implemented in ADBDriver")
return nil
}
func (ad *ADBDriver) TearDown() error {
log.Warn().Msg("TearDown not implemented in ADBDriver")
return nil
}
func (ad *ADBDriver) OpenUrl(url string) (err error) {
_, err = ad.runShellCommand(
"am", "start", "-W", "-a", "android.intent.action.VIEW",
"-d", fmt.Sprintf("'%s'", url))
return
}
func (ad *ADBDriver) PushImage(localPath string) error {
remotePath := path.Join("/sdcard/DCIM/Camera/", path.Base(localPath))
if err := ad.Device.PushFile(localPath, remotePath); err != nil {
return err
}
_, _ = ad.Device.RunShellCommand("am", "broadcast",
"-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
"-d", fmt.Sprintf("file://%s", remotePath))
return nil
}
func (ad *ADBDriver) ClearImages() error {
_, _ = ad.Device.RunShellCommand("rm", "-rf", "/sdcard/DCIM/Camera/*")
return nil
}
type ExportPoint struct {
Start int `json:"start" yaml:"start"`
End int `json:"end" yaml:"end"`
From interface{} `json:"from" yaml:"from"`
To interface{} `json:"to" yaml:"to"`
Operation string `json:"operation" yaml:"operation"`
Ext string `json:"ext" yaml:"ext"`
RunTime int `json:"run_time,omitempty" yaml:"run_time,omitempty"`
}
func ConvertPoints(lines []string) (eps []ExportPoint) {
log.Info().Msg("ConvertPoints")
log.Info().Msg(strings.Join(lines, "\n"))
for _, line := range lines {
if strings.Contains(line, "ext") {
idx := strings.Index(line, "{")
if idx == -1 {
continue
}
line = line[idx:]
p := ExportPoint{}
err := json.Unmarshal([]byte(line), &p)
if err != nil {
log.Error().Msg("failed to parse point data")
continue
}
log.Info().Msg(line)
eps = append(eps, p)
}
}
return
}

View File

@@ -1,593 +0,0 @@
package uixt
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/utf7"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
func NewUIA2Driver(device *AndroidDevice) (*UIA2Driver, error) {
log.Info().Interface("device", device).Msg("init android UIA2 driver")
adbDriver, err := NewADBDriver(device)
if err != nil {
return nil, err
}
driver := &UIA2Driver{
ADBDriver: adbDriver,
}
// setup driver
if err := driver.Setup(); err != nil {
return nil, err
}
// register driver session reset handler
driver.Session.RegisterResetHandler(driver.Setup)
return driver, nil
}
type UIA2Driver struct {
*ADBDriver
// cache to avoid repeated query
windowSize types.Size
}
func (ud *UIA2Driver) Setup() error {
localPort, err := ud.Device.Forward(ud.Device.Options.UIA2Port)
if err != nil {
return errors.Wrap(code.DeviceConnectionError,
fmt.Sprintf("forward port %d->%d failed: %v",
localPort, ud.Device.Options.UIA2Port, err))
}
err = ud.Session.SetupPortForward(localPort)
if err != nil {
return err
}
ud.Session.SetBaseURL(
fmt.Sprintf("http://forward-to-%d:%d/wd/hub",
localPort, ud.Device.Options.UIA2Port))
// uiautomator2 server must be started before
// check uiautomator server package installed
if !ud.Device.IsPackageInstalled(ud.Device.Options.UIA2ServerPackageName) {
return errors.Wrapf(code.MobileUIDriverAppNotInstalled,
"%s not installed", ud.Device.Options.UIA2ServerPackageName)
}
if !ud.Device.IsPackageInstalled(ud.Device.Options.UIA2ServerTestPackageName) {
return errors.Wrapf(code.MobileUIDriverAppNotInstalled,
"%s not installed", ud.Device.Options.UIA2ServerTestPackageName)
}
// TODO: check uiautomator server package running
// if dev.IsPackageRunning(UIA2ServerPackageName) {
// return nil
// }
// start uiautomator2 server
// go func() {
// if err := ud.startUIA2Server(); err != nil {
// log.Fatal().Err(err).Msg("start UIA2 failed")
// }
// }()
// time.Sleep(5 * time.Second) // wait for uiautomator2 server start
// create new session
err = ud.InitSession(nil)
if err != nil {
return err
}
return nil
}
func (ud *UIA2Driver) TearDown() error {
log.Warn().Msg("TearDown not implemented in UIA2Driver")
return nil
}
func (ud *UIA2Driver) InitSession(capabilities option.Capabilities) (err error) {
// register(postHandler, new InitSession("/wd/hub/session"))
var rawResp DriverRawResponse
data := make(map[string]interface{})
if len(capabilities) == 0 {
data["capabilities"] = make(map[string]interface{})
} else {
data["capabilities"] = map[string]interface{}{"alwaysMatch": capabilities}
}
if rawResp, err = ud.Session.POST(data, "/session"); err != nil {
return err
}
reply := new(struct{ Value struct{ SessionId string } })
if err = json.Unmarshal(rawResp, reply); err != nil {
return err
}
ud.Session.ID = reply.Value.SessionId
return nil
}
func (ud *UIA2Driver) DeleteSession() (err error) {
if ud.Session.ID == "" {
return nil
}
urlStr := fmt.Sprintf("/session/%s", ud.Session.ID)
if _, err = ud.Session.DELETE(urlStr); err == nil {
ud.Session.ID = ""
}
return err
}
func (ud *UIA2Driver) Status() (deviceStatus types.DeviceStatus, err error) {
// register(getHandler, new Status("/wd/hub/status"))
var rawResp DriverRawResponse
// Notice: use Driver.GET instead of httpGET to avoid loop calling
if rawResp, err = ud.Session.GET("/status"); err != nil {
return types.DeviceStatus{Ready: false}, err
}
reply := new(struct {
Value struct {
// Message string
Ready bool
}
})
if err = json.Unmarshal(rawResp, reply); err != nil {
return types.DeviceStatus{Ready: false}, err
}
return types.DeviceStatus{Ready: true}, nil
}
func (ud *UIA2Driver) DeviceInfo() (deviceInfo types.DeviceInfo, err error) {
// register(getHandler, new GetDeviceInfo("/wd/hub/session/:sessionId/appium/device/info"))
var rawResp DriverRawResponse
urlStr := fmt.Sprintf("/session/%s/appium/device/info", ud.Session.ID)
if rawResp, err = ud.Session.GET(urlStr); err != nil {
return types.DeviceInfo{}, err
}
reply := new(struct{ Value struct{ types.DeviceInfo } })
if err = json.Unmarshal(rawResp, reply); err != nil {
return types.DeviceInfo{}, err
}
deviceInfo = reply.Value.DeviceInfo
return
}
func (ud *UIA2Driver) BatteryInfo() (batteryInfo types.BatteryInfo, err error) {
// register(getHandler, new GetBatteryInfo("/wd/hub/session/:sessionId/appium/device/battery_info"))
var rawResp DriverRawResponse
urlStr := fmt.Sprintf("/session/%s/appium/device/battery_info", ud.Session.ID)
if rawResp, err = ud.Session.GET(urlStr); err != nil {
return types.BatteryInfo{}, err
}
reply := new(struct{ Value struct{ types.BatteryInfo } })
if err = json.Unmarshal(rawResp, reply); err != nil {
return types.BatteryInfo{}, err
}
if reply.Value.Level == -1 || reply.Value.Status == -1 {
return reply.Value.BatteryInfo, errors.New("cannot be retrieved from the system")
}
batteryInfo = reply.Value.BatteryInfo
return
}
func (ud *UIA2Driver) WindowSize() (size types.Size, err error) {
// register(getHandler, new GetDeviceSize("/wd/hub/session/:sessionId/window/:windowHandle/size"))
if !ud.windowSize.IsNil() {
// use cached window size
return ud.windowSize, nil
}
var rawResp DriverRawResponse
urlStr := fmt.Sprintf("/session/%s/window/:windowHandle/size", ud.Session.ID)
if rawResp, err = ud.Session.GET(urlStr); err != nil {
return types.Size{}, errors.Wrap(err, "get window size failed by UIA2 request")
}
reply := new(struct{ Value struct{ types.Size } })
if err = json.Unmarshal(rawResp, reply); err != nil {
return types.Size{}, errors.Wrap(err, "get window size failed by UIA2 response")
}
size = reply.Value.Size
// check orientation
orientation, err := ud.Orientation()
if err != nil {
log.Warn().Err(err).Msgf("window size get orientation failed, use default orientation")
orientation = types.OrientationPortrait
}
if orientation != types.OrientationPortrait {
size.Width, size.Height = size.Height, size.Width
}
ud.windowSize = size // cache window size
return size, nil
}
// Back simulates a short press on the BACK button.
func (ud *UIA2Driver) Back() (err error) {
// register(postHandler, new PressBack("/wd/hub/session/:sessionId/back"))
urlStr := fmt.Sprintf("/session/%s/back", ud.Session.ID)
_, err = ud.Session.POST(nil, urlStr)
return
}
func (ud *UIA2Driver) PressKeyCodes(keyCode KeyCode, metaState KeyMeta, flags ...KeyFlag) (err error) {
// register(postHandler, new PressKeyCodeAsync("/wd/hub/session/:sessionId/appium/device/press_keycode"))
data := map[string]interface{}{
"keycode": keyCode,
}
if metaState != KMEmpty {
data["metastate"] = metaState
}
if len(flags) != 0 {
data["flags"] = flags[0]
}
urlStr := fmt.Sprintf("/session/%s/appium/device/press_keycode", ud.Session.ID)
_, err = ud.Session.POST(data, urlStr)
return
}
func (ud *UIA2Driver) Orientation() (orientation types.Orientation, err error) {
// [[FBRoute GET:@"/orientation"] respondWithTarget:self action:@selector(handleGetOrientation:)]
var rawResp DriverRawResponse
urlStr := fmt.Sprintf("/session/%s/orientation", ud.Session.ID)
if rawResp, err = ud.Session.GET(urlStr); err != nil {
return "", err
}
reply := new(struct{ Value types.Orientation })
if err = json.Unmarshal(rawResp, reply); err != nil {
return "", err
}
orientation = reply.Value
return
}
func (ud *UIA2Driver) DoubleTap(x, y float64, opts ...option.ActionOption) error {
var err error
x, y, err = convertToAbsolutePoint(ud, x, y)
if err != nil {
return err
}
actionOptions := option.NewActionOptions(opts...)
x, y = actionOptions.ApplyOffset(x, y)
data := map[string]interface{}{
"actions": []interface{}{
map[string]interface{}{
"type": "pointer",
"parameters": map[string]string{"pointerType": "touch"},
"id": "touch",
"actions": []interface{}{
map[string]interface{}{"type": "pointerMove", "duration": 0, "x": x, "y": y, "origin": "viewport"},
map[string]interface{}{"type": "pointerDown", "duration": 0, "button": 0},
map[string]interface{}{"type": "pointerUp", "duration": 0, "button": 0},
map[string]interface{}{"type": "pointerDown", "duration": 0, "button": 0},
map[string]interface{}{"type": "pointerUp", "duration": 0, "button": 0},
},
},
},
}
urlStr := fmt.Sprintf("/session/%s/actions/tap", ud.Session.ID)
_, err = ud.Session.POST(data, urlStr)
return err
}
func (ud *UIA2Driver) TapXY(x, y float64, opts ...option.ActionOption) error {
// register(postHandler, new Tap("/wd/hub/session/:sessionId/appium/tap"))
absX, absY, err := convertToAbsolutePoint(ud, x, y)
if err != nil {
return err
}
return ud.TapAbsXY(absX, absY, opts...)
}
func (ud *UIA2Driver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
// register(postHandler, new Tap("/wd/hub/session/:sessionId/appium/tap"))
actionOptions := option.NewActionOptions(opts...)
x, y = actionOptions.ApplyOffset(x, y)
duration := 100.0
if actionOptions.PressDuration > 0 {
duration = actionOptions.PressDuration * 1000 // convert to ms
}
data := map[string]interface{}{
"actions": []interface{}{
map[string]interface{}{
"type": "pointer",
"parameters": map[string]string{"pointerType": "touch"},
"id": "touch",
"actions": []interface{}{
map[string]interface{}{"type": "pointerMove", "duration": 0, "x": x, "y": y, "origin": "viewport"},
map[string]interface{}{"type": "pointerDown", "duration": 0, "button": 0},
map[string]interface{}{"type": "pause", "duration": duration},
map[string]interface{}{"type": "pointerUp", "duration": 0, "button": 0},
},
},
},
}
option.MergeOptions(data, opts...)
urlStr := fmt.Sprintf("/session/%s/actions/tap", ud.Session.ID)
_, err := ud.Session.POST(data, urlStr)
return err
}
func (ud *UIA2Driver) TouchAndHold(x, y float64, opts ...option.ActionOption) (err error) {
actionOptions := option.NewActionOptions(opts...)
x, y = actionOptions.ApplyOffset(x, y)
duration := actionOptions.Duration
if duration == 0 {
duration = 1.0
}
// register(postHandler, new TouchLongClick("/wd/hub/session/:sessionId/touch/longclick"))
data := map[string]interface{}{
"params": map[string]interface{}{
"x": x,
"y": y,
"duration": int(duration * 1000),
},
}
urlStr := fmt.Sprintf("/session/%s/touch/longclick", ud.Session.ID)
_, err = ud.Session.POST(data, urlStr)
return
}
// Drag performs a swipe from one coordinate to another coordinate. You can control
// the smoothness and speed of the swipe by specifying the number of steps.
// Each step execution is throttled to 5 milliseconds per step, so for a 100
// steps, the swipe will take around 0.5 seconds to complete.
func (ud *UIA2Driver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
var err error
fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(ud, fromX, fromY, toX, toY)
if err != nil {
return err
}
data := map[string]interface{}{
"startX": fromX,
"startY": fromY,
"endX": toX,
"endY": toY,
}
option.MergeOptions(data, opts...)
// register(postHandler, new Drag("/wd/hub/session/:sessionId/touch/drag"))
urlStr := fmt.Sprintf("/session/%s/touch/drag", ud.Session.ID)
_, err = ud.Session.POST(data, urlStr)
return err
}
// Swipe performs a swipe from one coordinate to another using the number of steps
// to determine smoothness and speed. Each step execution is throttled to 5ms
// per step. So for a 100 steps, the swipe will take about 1/2 second to complete.
//
// `steps` is the number of move steps sent to the system
func (ud *UIA2Driver) Swipe(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
// register(postHandler, new Swipe("/wd/hub/session/:sessionId/touch/perform"))
var err error
actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(ud, fromX, fromY, toX, toY)
if err != nil {
return err
}
duration := 200.0
if actionOptions.PressDuration > 0 {
duration = actionOptions.PressDuration * 1000 // ms
}
data := map[string]interface{}{
"actions": []interface{}{
map[string]interface{}{
"type": "pointer",
"parameters": map[string]string{"pointerType": "touch"},
"id": "touch",
"actions": []interface{}{
map[string]interface{}{"type": "pointerMove", "duration": 0, "x": fromX, "y": fromY, "origin": "viewport"},
map[string]interface{}{"type": "pointerDown", "duration": 0, "button": 0},
map[string]interface{}{"type": "pointerMove", "duration": duration, "x": toX, "y": toY, "origin": "viewport"},
map[string]interface{}{"type": "pointerUp", "duration": 0, "button": 0},
},
},
},
}
option.MergeOptions(data, opts...)
urlStr := fmt.Sprintf("/session/%s/actions/swipe", ud.Session.ID)
_, err = ud.Session.POST(data, urlStr)
return err
}
func (ud *UIA2Driver) SetPasteboard(contentType types.PasteboardType, content string) (err error) {
lbl := content
const defaultLabelLen = 10
if len(lbl) > defaultLabelLen {
lbl = lbl[:defaultLabelLen]
}
data := map[string]interface{}{
"contentType": contentType,
"label": lbl,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
}
// register(postHandler, new SetClipboard("/wd/hub/session/:sessionId/appium/device/set_clipboard"))
urlStr := fmt.Sprintf("/session/%s/appium/device/set_clipboard", ud.Session.ID)
_, err = ud.Session.POST(data, urlStr)
return
}
func (ud *UIA2Driver) GetPasteboard(contentType types.PasteboardType) (raw *bytes.Buffer, err error) {
if len(contentType) == 0 {
contentType = types.PasteboardTypePlaintext
}
// register(postHandler, new GetClipboard("/wd/hub/session/:sessionId/appium/device/get_clipboard"))
data := map[string]interface{}{
"contentType": contentType[0],
}
var rawResp DriverRawResponse
urlStr := fmt.Sprintf("/session/%s/appium/device/get_clipboard", ud.Session.ID)
if rawResp, err = ud.Session.POST(data, urlStr); err != nil {
return
}
reply := new(struct{ Value string })
if err = json.Unmarshal(rawResp, reply); err != nil {
return
}
if data, err := base64.StdEncoding.DecodeString(reply.Value); err != nil {
raw.Write([]byte(reply.Value))
} else {
raw.Write(data)
}
return
}
// SendKeys Android input does not support setting frequency.
func (ud *UIA2Driver) Input(text string, opts ...option.ActionOption) (err error) {
// register(postHandler, new SendKeysToElement("/wd/hub/session/:sessionId/keys"))
// https://github.com/appium/appium-uiautomator2-server/blob/master/app/src/main/java/io/appium/uiautomator2/handler/SendKeysToElement.java#L76-L85
err = ud.SendUnicodeKeys(text, opts...)
if err == nil {
return nil
}
data := map[string]interface{}{
"text": text,
}
option.MergeOptions(data, opts...)
urlStr := fmt.Sprintf("/session/%s/keys", ud.Session.ID)
_, err = ud.Session.POST(data, urlStr)
return
}
func (ud *UIA2Driver) SendUnicodeKeys(text string, opts ...option.ActionOption) (err error) {
// If the Unicode IME is not installed, fall back to the old interface.
// There might be differences in the tracking schemes across different phones, and it is pending further verification.
// In release version: without the Unicode IME installed, the test cannot execute.
if !ud.IsUnicodeIMEInstalled() {
return fmt.Errorf("appium unicode ime not installed")
}
currentIme, err := ud.ADBDriver.GetIme()
if err != nil {
return
}
if currentIme != option.UnicodeImePackageName {
defer func() {
_ = ud.ADBDriver.SetIme(currentIme)
}()
err = ud.ADBDriver.SetIme(option.UnicodeImePackageName)
if err != nil {
log.Warn().Err(err).Msgf("set Unicode Ime failed")
return
}
}
encodedStr, err := utf7.Encoding.NewEncoder().String(text)
if err != nil {
log.Warn().Err(err).Msgf("encode text with modified utf7 failed")
return
}
err = ud.SendActionKey(encodedStr, opts...)
return
}
func (ud *UIA2Driver) SendActionKey(text string, opts ...option.ActionOption) (err error) {
var actions []interface{}
for i, c := range text {
actions = append(actions, map[string]interface{}{"type": "keyDown", "value": string(c)},
map[string]interface{}{"type": "keyUp", "value": string(c)})
if i != len(text)-1 {
actions = append(actions, map[string]interface{}{"type": "pause", "duration": 40})
}
}
data := map[string]interface{}{
"actions": []interface{}{
map[string]interface{}{
"type": "key",
"id": "key",
"actions": actions,
},
},
}
option.MergeOptions(data, opts...)
urlStr := fmt.Sprintf("/session/%s/actions/keys", ud.Session.ID)
_, err = ud.Session.POST(data, urlStr)
return
}
func (ud *UIA2Driver) Rotation() (rotation types.Rotation, err error) {
// register(getHandler, new GetRotation("/wd/hub/session/:sessionId/rotation"))
var rawResp DriverRawResponse
urlStr := fmt.Sprintf("/session/%s/rotation", ud.Session.ID)
if rawResp, err = ud.Session.GET(urlStr); err != nil {
return types.Rotation{}, err
}
reply := new(struct{ Value types.Rotation })
if err = json.Unmarshal(rawResp, reply); err != nil {
return types.Rotation{}, err
}
rotation = reply.Value
return
}
func (ud *UIA2Driver) ScreenShot(opts ...option.ActionOption) (raw *bytes.Buffer, err error) {
// https://bytedance.larkoffice.com/docx/C8qEdmSHnoRvMaxZauocMiYpnLh
// ui2截图受内存影响改为adb截图
return ud.ADBDriver.ScreenShot(opts...)
}
func (ud *UIA2Driver) Source(srcOpt ...option.SourceOption) (source string, err error) {
// register(getHandler, new Source("/wd/hub/session/:sessionId/source"))
var rawResp DriverRawResponse
urlStr := fmt.Sprintf("/session/%s/source", ud.Session.ID)
if rawResp, err = ud.Session.GET(urlStr); err != nil {
return "", err
}
reply := new(struct{ Value string })
if err = json.Unmarshal(rawResp, reply); err != nil {
return "", err
}
source = reply.Value
return
}
func (ud *UIA2Driver) startUIA2Server() error {
const maxRetries = 3
for attempt := 1; attempt <= maxRetries; attempt++ {
log.Info().Str("package", ud.Device.Options.UIA2ServerTestPackageName).
Int("attempt", attempt).Msg("start uiautomator server")
// $ adb shell am instrument -w $UIA2ServerTestPackageName
// -w: wait for instrumentation to finish before returning.
// Required for test runners.
out, err := ud.Device.RunShellCommand("am", "instrument", "-w",
ud.Device.Options.UIA2ServerTestPackageName)
if err != nil {
return errors.Wrap(err, "start uiautomator server failed")
}
if strings.Contains(out, "Process crashed") {
log.Error().Msg("uiautomator server crashed, retrying...")
}
}
return errors.Wrapf(code.MobileUIDriverAppCrashed,
"uiautomator server crashed %d times", maxRetries)
}
func (ud *UIA2Driver) stopUIA2Server() error {
_, err := ud.Device.RunShellCommand("am", "force-stop",
ud.Device.Options.UIA2ServerPackageName)
return err
}

View File

@@ -1,881 +0,0 @@
package uixt
// See https://developer.android.com/reference/android/view/KeyEvent
type KeyMeta int
const (
KMEmpty KeyMeta = 0 // As a `null`
KMCapLocked KeyMeta = 0x100 // SHIFT key locked in CAPS mode.
KMAltLocked KeyMeta = 0x200 // ALT key locked.
KMSymLocked KeyMeta = 0x400 // SYM key locked.
KMSelecting KeyMeta = 0x800 // Text is in selection mode.
KMAltOn KeyMeta = 0x02 // This mask is used to check whether one of the ALT meta keys is pressed.
KMAltLeftOn KeyMeta = 0x10 // This mask is used to check whether the left ALT meta key is pressed.
KMAltRightOn KeyMeta = 0x20 // This mask is used to check whether the right the ALT meta key is pressed.
KMShiftOn KeyMeta = 0x1 // This mask is used to check whether one of the SHIFT meta keys is pressed.
KMShiftLeftOn KeyMeta = 0x40 // This mask is used to check whether the left SHIFT meta key is pressed.
KMShiftRightOn KeyMeta = 0x80 // This mask is used to check whether the right SHIFT meta key is pressed.
KMSymOn KeyMeta = 0x4 // This mask is used to check whether the SYM meta key is pressed.
KMFunctionOn KeyMeta = 0x8 // This mask is used to check whether the FUNCTION meta key is pressed.
KMCtrlOn KeyMeta = 0x1000 // This mask is used to check whether one of the CTRL meta keys is pressed.
KMCtrlLeftOn KeyMeta = 0x2000 // This mask is used to check whether the left CTRL meta key is pressed.
KMCtrlRightOn KeyMeta = 0x4000 // This mask is used to check whether the right CTRL meta key is pressed.
KMMetaOn KeyMeta = 0x10000 // This mask is used to check whether one of the META meta keys is pressed.
KMMetaLeftOn KeyMeta = 0x20000 // This mask is used to check whether the left META meta key is pressed.
KMMetaRightOn KeyMeta = 0x40000 // This mask is used to check whether the right META meta key is pressed.
KMCapsLockOn KeyMeta = 0x100000 // This mask is used to check whether the CAPS LOCK meta key is on.
KMNumLockOn KeyMeta = 0x200000 // This mask is used to check whether the NUM LOCK meta key is on.
KMScrollLockOn KeyMeta = 0x400000 // This mask is used to check whether the SCROLL LOCK meta key is on.
KMShiftMask = KMShiftOn | KMShiftLeftOn | KMShiftRightOn
KMAltMask = KMAltOn | KMAltLeftOn | KMAltRightOn
KMCtrlMask = KMCtrlOn | KMCtrlLeftOn | KMCtrlRightOn
KMMetaMask = KMMetaOn | KMMetaLeftOn | KMMetaRightOn
)
type KeyFlag int
const (
// KFWokeHere This mask is set if the device woke because of this key event.
// Deprecated
KFWokeHere KeyFlag = 0x1
// KFSoftKeyboard This mask is set if the key event was generated by a software keyboard.
KFSoftKeyboard KeyFlag = 0x2
// KFKeepTouchMode This mask is set if we don't want the key event to cause us to leave touch mode.
KFKeepTouchMode KeyFlag = 0x4
// KFFromSystem This mask is set if an event was known to come from a trusted part
// of the system. That is, the event is known to come from the user,
// and could not have been spoofed by a third party component.
KFFromSystem KeyFlag = 0x8
// KFEditorAction This mask is used for compatibility, to identify enter keys that are
// coming from an IME whose enter key has been auto-labelled "next" or
// "done". This allows TextView to dispatch these as normal enter keys
// for old applications, but still do the appropriate action when receiving them.
KFEditorAction KeyFlag = 0x10
// KFCanceled When associated with up key events, this indicates that the key press
// has been canceled. Typically this is used with virtual touch screen
// keys, where the user can slide from the virtual key area on to the
// display: in that case, the application will receive a canceled up
// event and should not perform the action normally associated with the
// key. Note that for this to work, the application can not perform an
// action for a key until it receives an up or the long press timeout has expired.
KFCanceled KeyFlag = 0x20
// KFVirtualHardKey This key event was generated by a virtual (on-screen) hard key area.
// Typically this is an area of the touchscreen, outside of the regular
// display, dedicated to "hardware" buttons.
KFVirtualHardKey KeyFlag = 0x40
// KFLongPress This flag is set for the first key repeat that occurs after the long press timeout.
KFLongPress KeyFlag = 0x80
// KFCanceledLongPress Set when a key event has `KFCanceled` set because a long
// press action was executed while it was down.
KFCanceledLongPress KeyFlag = 0x100
// KFTracking Set for `ACTION_UP` when this event's key code is still being
// tracked from its initial down. That is, somebody requested that tracking
// started on the key down and a long press has not caused
// the tracking to be canceled.
KFTracking KeyFlag = 0x200
// KFFallback Set when a key event has been synthesized to implement default behavior
// for an event that the application did not handle.
// Fallback key events are generated by unhandled trackball motions
// (to emulate a directional keypad) and by certain unhandled key presses
// that are declared in the key map (such as special function numeric keypad
// keys when numlock is off).
KFFallback KeyFlag = 0x400
// KFPredispatch Signifies that the key is being predispatched.
// KFPredispatch KeyFlag = 0x20000000
// KFStartTracking Private control to determine when an app is tracking a key sequence.
// KFStartTracking KeyFlag = 0x40000000
// KFTainted Private flag that indicates when the system has detected that this key event
// may be inconsistent with respect to the sequence of previously delivered key events,
// such as when a key up event is sent but the key was not down.
// KFTainted KeyFlag = 0x80000000
)
type KeyCode int
const (
_ KeyCode = 0 // Unknown key code.
// KCSoftLeft Soft Left key
// Usually situated below the display on phones and used as a multi-function
// feature key for selecting a software defined function shown on the bottom left
// of the display.
KCSoftLeft KeyCode = 1
// KCSoftRight Soft Right key.
// Usually situated below the display on phones and used as a multi-function
// feature key for selecting a software defined function shown on the bottom right
// of the display.
KCSoftRight KeyCode = 2
// KCHome Home key.
// This key is handled by the framework and is never delivered to applications.
KCHome KeyCode = 3
KCBack KeyCode = 4 // Back key
KCCall KeyCode = 5 // Call key
KCEndCall KeyCode = 6 // End Call key
KC0 KeyCode = 7 // '0' key
KC1 KeyCode = 8 // '1' key
KC2 KeyCode = 9 // '2' key
KC3 KeyCode = 10 // '3' key
KC4 KeyCode = 11 // '4' key
KC5 KeyCode = 12 // '5' key
KC6 KeyCode = 13 // '6' key
KC7 KeyCode = 14 // '7' key
KC8 KeyCode = 15 // '8' key
KC9 KeyCode = 16 // '9' key
KCStar KeyCode = 17 // '*' key
KCPound KeyCode = 18 // '#' key
// KCDPadUp KeycodeDPadUp Directional Pad Up key.
// May also be synthesized from trackball motions.
KCDPadUp KeyCode = 19
// KCDPadDown Directional Pad Down key.
// May also be synthesized from trackball motions.
KCDPadDown KeyCode = 20
// KCDPadLeft Directional Pad Left key.
// May also be synthesized from trackball motions.
KCDPadLeft KeyCode = 21
// KCDPadRight Directional Pad Right key.
// May also be synthesized from trackball motions.
KCDPadRight KeyCode = 22
// KCDPadCenter Directional Pad Center key.
// May also be synthesized from trackball motions.
KCDPadCenter KeyCode = 23
// KCVolumeUp Volume Up key.
// Adjusts the speaker volume up.
KCVolumeUp KeyCode = 24
// KCVolumeDown Volume Down key.
// Adjusts the speaker volume down.
KCVolumeDown KeyCode = 25
// KCPower Power key.
KCPower KeyCode = 26
// KCCamera Camera key.
// Used to launch a camera application or take pictures.
KCCamera KeyCode = 27
KCClear KeyCode = 28 // Clear key
KCa KeyCode = 29 // 'a' key
KCb KeyCode = 30 // 'b' key
KCc KeyCode = 31 // 'c' key
KCd KeyCode = 32 // 'd' key
KCe KeyCode = 33 // 'e' key
KCf KeyCode = 34 // 'f' key
KCg KeyCode = 35 // 'g' key
KCh KeyCode = 36 // 'h' key
KCi KeyCode = 37 // 'i' key
KCj KeyCode = 38 // 'j' key
KCk KeyCode = 39 // 'k' key
KCl KeyCode = 40 // 'l' key
KCm KeyCode = 41 // 'm' key
KCn KeyCode = 42 // 'n' key
KCo KeyCode = 43 // 'o' key
KCp KeyCode = 44 // 'p' key
KCq KeyCode = 45 // 'q' key
KCr KeyCode = 46 // 'r' key
KCs KeyCode = 47 // 's' key
KCt KeyCode = 48 // 't' key
KCu KeyCode = 49 // 'u' key
KCv KeyCode = 50 // 'v' key
KCw KeyCode = 51 // 'w' key
KCx KeyCode = 52 // 'x' key
KCy KeyCode = 53 // 'y' key
KCz KeyCode = 54 // 'z' key
KCComma KeyCode = 55 // ',' key
KCPeriod KeyCode = 56 // '.' key
KCAltLeft KeyCode = 57 // Left Alt modifier key
KCAltRight KeyCode = 58 // Right Alt modifier key
KCShiftLeft KeyCode = 59 // Left Shift modifier key
KCShiftRight KeyCode = 60 // Right Shift modifier key
KCTab KeyCode = 61 // Tab key
KCSpace KeyCode = 62 // Space key
// KCSym Symbol modifier key.
// Used to enter alternate symbols.
KCSym KeyCode = 63
// KCExplorer Explorer special function key.
// Used to launch a browser application.
KCExplorer KeyCode = 64
// KCEnvelope Envelope special function key.
// Used to launch a mail application.
KCEnvelope KeyCode = 65
// KCEnter Enter key.
KCEnter KeyCode = 66
// KCDel Backspace key.
// Deletes characters before the insertion point, unlike `KCForwardDel`.
KCDel KeyCode = 67
KCGrave KeyCode = 68 // '`' (backtick) key
KCMinus KeyCode = 69 // '-'
KCEquals KeyCode = 70 // '=' key
KCLeftBracket KeyCode = 71 // '[' key
KCRightBracket KeyCode = 72 // ']' key
KCBackslash KeyCode = 73 // '\' key
KCSemicolon KeyCode = 74 // '' key
KCApostrophe KeyCode = 75 // ''' (apostrophe) key
KCSlash KeyCode = 76 // '/' key
KCAt KeyCode = 77 // '@' key
// KCNum Number modifier key.
// Used to enter numeric symbols.
// This key is not Num Lock; it is more like `KCAltLeft` and is
// interpreted as an ALT key by {@link android.text.method.MetaKeyKeyListener}.
KCNum KeyCode = 78
// KCHeadsetHook Headset Hook key.
// Used to hang up calls and stop media.
KCHeadsetHook KeyCode = 79
// KCFocus Camera Focus key.
// Used to focus the camera.
// *Camera* focus
KCFocus KeyCode = 80
KCPlus KeyCode = 81 // '+' key.
KCMenu KeyCode = 82 // Menu key.
KCNotification KeyCode = 83 // Notification key.
KCSearch KeyCode = 84 // Search key.
KCMediaPlayPause KeyCode = 85 // Play/Pause media key.
KCMediaStop KeyCode = 86 // Stop media key.
KCMediaNext KeyCode = 87 // Play Next media key.
KCMediaPrevious KeyCode = 88 // Play Previous media key.
KCMediaRewind KeyCode = 89 // Rewind media key.
KCMediaFastForward KeyCode = 90 // Fast Forward media key.
// KCMute Mute key.
// Mutes the microphone, unlike `KCVolumeMute`
KCMute KeyCode = 91
// KCPageUp Page Up key.
KCPageUp KeyCode = 92
// KCPageDown Page Down key.
KCPageDown KeyCode = 93
// KCPictSymbols Picture Symbols modifier key.
// Used to switch symbol sets (Emoji, Kao-moji).
// switch symbol-sets (Emoji,Kao-moji)
KCPictSymbols KeyCode = 94
// KCSwitchCharset Switch Charset modifier key.
// Used to switch character sets (Kanji, Katakana).
// switch char-sets (Kanji,Katakana)
KCSwitchCharset KeyCode = 95
// KCButtonA A Button key.
// On a game controller, the A button should be either the button labeled A
// or the first button on the bottom row of controller buttons.
KCButtonA KeyCode = 96
// KCButtonB B Button key.
// On a game controller, the B button should be either the button labeled B
// or the second button on the bottom row of controller buttons.
KCButtonB KeyCode = 97
// KCButtonC C Button key.
// On a game controller, the C button should be either the button labeled C
// or the third button on the bottom row of controller buttons.
KCButtonC KeyCode = 98
// KCButtonX X Button key.
// On a game controller, the X button should be either the button labeled X
// or the first button on the upper row of controller buttons.
KCButtonX KeyCode = 99
// KCButtonY Y Button key.
// On a game controller, the Y button should be either the button labeled Y
// or the second button on the upper row of controller buttons.
KCButtonY KeyCode = 100
// KCButtonZ Z Button key.
// On a game controller, the Z button should be either the button labeled Z
// or the third button on the upper row of controller buttons.
KCButtonZ KeyCode = 101
// KCButtonL1 L1 Button key.
// On a game controller, the L1 button should be either the button labeled L1 (or L)
// or the top left trigger button.
KCButtonL1 KeyCode = 102
// KCButtonR1 R1 Button key.
// On a game controller, the R1 button should be either the button labeled R1 (or R)
// or the top right trigger button.
KCButtonR1 KeyCode = 103
// KCButtonL2 L2 Button key.
// On a game controller, the L2 button should be either the button labeled L2
// or the bottom left trigger button.
KCButtonL2 KeyCode = 104
// KCButtonR2 R2 Button key.
// On a game controller, the R2 button should be either the button labeled R2
// or the bottom right trigger button.
KCButtonR2 KeyCode = 105
// KCButtonTHUMBL Left Thumb Button key.
// On a game controller, the left thumb button indicates that the left (or only)
// joystick is pressed.
KCButtonTHUMBL KeyCode = 106
// KCButtonTHUMBR Right Thumb Button key.
// On a game controller, the right thumb button indicates that the right
// joystick is pressed.
KCButtonTHUMBR KeyCode = 107
// KCButtonStart Start Button key.
// On a game controller, the button labeled Start.
KCButtonStart KeyCode = 108
// KCButtonSelect Select Button key.
// On a game controller, the button labeled Select.
KCButtonSelect KeyCode = 109
// KCButtonMode Mode Button key.
// On a game controller, the button labeled Mode.
KCButtonMode KeyCode = 110
// KCEscape Escape key.
KCEscape KeyCode = 111
// KCForwardDel Forward Delete key.
// Deletes characters ahead of the insertion point, unlike `KCDel`.
KCForwardDel KeyCode = 112
KCCtrlLeft KeyCode = 113 // Left Control modifier key
KCCtrlRight KeyCode = 114 // Right Control modifier key
KCCapsLock KeyCode = 115 // Caps Lock key
KCScrollLock KeyCode = 116 // Scroll Lock key
KCMetaLeft KeyCode = 117 // Left Meta modifier key
KCMetaRight KeyCode = 118 // Right Meta modifier key
KCFunction KeyCode = 119 // Function modifier key
KCSysRq KeyCode = 120 // System Request / Print Screen key
KCBreak KeyCode = 121 // Break / Pause key
// KCMoveHome Home Movement key.
// Used for scrolling or moving the cursor around to the start of a line
// or to the top of a list.
KCMoveHome KeyCode = 122
// KCMoveEnd End Movement key.
// Used for scrolling or moving the cursor around to the end of a line
// or to the bottom of a list.
KCMoveEnd KeyCode = 123
// KCInsert Insert key.
// Toggles insert / overwrite edit mode.
KCInsert KeyCode = 124
// KCForward Forward key.
// Navigates forward in the history stack. Complement of `KCBack`.
KCForward KeyCode = 125
// KCMediaPlay Play media key.
KCMediaPlay KeyCode = 126
// KCMediaPause Pause media key.
KCMediaPause KeyCode = 127
// KCMediaClose Close media key.
// May be used to close a CD tray, for example.
KCMediaClose KeyCode = 128
// KCMediaEject Eject media key.
// May be used to eject a CD tray, for example.
KCMediaEject KeyCode = 129
// KCMediaRecord Record media key.
KCMediaRecord KeyCode = 130
KCF1 KeyCode = 131 // F1 key.
KCF2 KeyCode = 132 // F2 key.
KCF3 KeyCode = 133 // F3 key.
KCF4 KeyCode = 134 // F4 key.
KCF5 KeyCode = 135 // F5 key.
KCF6 KeyCode = 136 // F6 key.
KCF7 KeyCode = 137 // F7 key.
KCF8 KeyCode = 138 // F8 key.
KCF9 KeyCode = 139 // F9 key.
KCF10 KeyCode = 140 // F10 key.
KCF11 KeyCode = 141 // F11 key.
KCF12 KeyCode = 142 // F12 key.
// KCNumLock Num Lock key.
// This is the Num Lock key; it is different from `KCNum`.
// This key alters the behavior of other keys on the numeric keypad.
KCNumLock KeyCode = 143
KCNumpad0 KeyCode = 144 // Numeric keypad '0' key
KCNumpad1 KeyCode = 145 // Numeric keypad '1' key
KCNumpad2 KeyCode = 146 // Numeric keypad '2' key
KCNumpad3 KeyCode = 147 // Numeric keypad '3' key
KCNumpad4 KeyCode = 148 // Numeric keypad '4' key
KCNumpad5 KeyCode = 149 // Numeric keypad '5' key
KCNumpad6 KeyCode = 150 // Numeric keypad '6' key
KCNumpad7 KeyCode = 151 // Numeric keypad '7' key
KCNumpad8 KeyCode = 152 // Numeric keypad '8' key
KCNumpad9 KeyCode = 153 // Numeric keypad '9' key
KCNumpadDivide KeyCode = 154 // Numeric keypad '/' key (for division)
KCNumpadMultiply KeyCode = 155 // Numeric keypad '*' key (for multiplication)
KCNumpadSubtract KeyCode = 156 // Numeric keypad '-' key (for subtraction)
KCNumpadAdd KeyCode = 157 // Numeric keypad '+' key (for addition)
KCNumpadDot KeyCode = 158 // Numeric keypad '.' key (for decimals or digit grouping)
KCNumpadComma KeyCode = 159 // Numeric keypad ',' key (for decimals or digit grouping)
KCNumpadEnter KeyCode = 160 // Numeric keypad Enter key
KCNumpadEquals KeyCode = 161 // Numeric keypad 'KeyCode =' key
KCNumpadLeftParen KeyCode = 162 // Numeric keypad '(' key
KCNumpadRightParen KeyCode = 163 // Numeric keypad ')' key
// KCVolumeMute Volume Mute key.
// Mutes the speaker, unlike `KCMute`.
// This key should normally be implemented as a toggle such that the first press
// mutes the speaker and the second press restores the original volume.
KCVolumeMute KeyCode = 164
// KCInfo Info key.
// Common on TV remotes to show additional information related to what is
// currently being viewed.
KCInfo KeyCode = 165
// KCChannelUp Channel up key.
// On TV remotes, increments the television channel.
KCChannelUp KeyCode = 166
// KCChannelDown Channel down key.
// On TV remotes, decrements the television channel.
KCChannelDown KeyCode = 167
// KCZoomIn Zoom in key.
KCZoomIn KeyCode = 168
// KCZoomOut Zoom out key.
KCZoomOut KeyCode = 169
// KCTv TV key.
// On TV remotes, switches to viewing live TV.
KCTv KeyCode = 170
// KCWindow Window key.
// On TV remotes, toggles picture-in-picture mode or other windowing functions.
// On Android Wear devices, triggers a display offset.
KCWindow KeyCode = 171
// KCGuide Guide key.
// On TV remotes, shows a programming guide.
KCGuide KeyCode = 172
// KCDvr DVR key.
// On some TV remotes, switches to a DVR mode for recorded shows.
KCDvr KeyCode = 173
// KCBookmark Bookmark key.
// On some TV remotes, bookmarks content or web pages.
KCBookmark KeyCode = 174
// KCCaptions Toggle captions key.
// Switches the mode for closed-captioning text, for example during television shows.
KCCaptions KeyCode = 175
// KCSettings Settings key.
// Starts the system settings activity.
KCSettings KeyCode = 176
// KCTvPower TV power key.
// On TV remotes, toggles the power on a television screen.
KCTvPower KeyCode = 177
// KCTvInput TV input key.
// On TV remotes, switches the input on a television screen.
KCTvInput KeyCode = 178
// KCStbPower Set-top-box power key.
// On TV remotes, toggles the power on an external Set-top-box.
KCStbPower KeyCode = 179
// KCStbInput Set-top-box input key.
// On TV remotes, switches the input mode on an external Set-top-box.
KCStbInput KeyCode = 180
// KCAvrPower A/V Receiver power key.
// On TV remotes, toggles the power on an external A/V Receiver.
KCAvrPower KeyCode = 181
// KCAvrInput A/V Receiver input key.
// On TV remotes, switches the input mode on an external A/V Receiver.
KCAvrInput KeyCode = 182
// KCProgRed Red "programmable" key.
// On TV remotes, acts as a contextual/programmable key.
KCProgRed KeyCode = 183
// KCProgGreen Green "programmable" key.
// On TV remotes, actsas a contextual/programmable key.
KCProgGreen KeyCode = 184
// KCProgYellow Yellow "programmable" key.
// On TV remotes, acts as a contextual/programmable key.
KCProgYellow KeyCode = 185
// KCProgBlue Blue "programmable" key.
// On TV remotes, acts as a contextual/programmable key.
KCProgBlue KeyCode = 186
// KCAppSwitch App switch key.
// Should bring up the application switcher dialog.
KCAppSwitch KeyCode = 187
KCButton1 KeyCode = 188 // Generic Game Pad Button #1
KCButton2 KeyCode = 189 // Generic Game Pad Button #2
KCButton3 KeyCode = 190 // Generic Game Pad Button #3
KCButton4 KeyCode = 191 // Generic Game Pad Button #4
KCButton5 KeyCode = 192 // Generic Game Pad Button #5
KCButton6 KeyCode = 193 // Generic Game Pad Button #6
KCButton7 KeyCode = 194 // Generic Game Pad Button #7
KCButton8 KeyCode = 195 // Generic Game Pad Button #8
KCButton9 KeyCode = 196 // Generic Game Pad Button #9
KCButton10 KeyCode = 197 // Generic Game Pad Button #10
KCButton11 KeyCode = 198 // Generic Game Pad Button #11
KCButton12 KeyCode = 199 // Generic Game Pad Button #12
KCButton13 KeyCode = 200 // Generic Game Pad Button #13
KCButton14 KeyCode = 201 // Generic Game Pad Button #14
KCButton15 KeyCode = 202 // Generic Game Pad Button #15
KCButton16 KeyCode = 203 // Generic Game Pad Button #16
// KCLanguageSwitch Language Switch key.
// Toggles the current input language such as switching between English and Japanese on
// a QWERTY keyboard. On some devices, the same function may be performed by
// pressing Shift+Spacebar.
KCLanguageSwitch KeyCode = 204
// Manner Mode key.
// Toggles silent or vibrate mode on and off to make the device behave more politely
// in certain settings such as on a crowded train. On some devices, the key may only
// operate when long-pressed.
KCMannerMode KeyCode = 205
// 3D Mode key.
// Toggles the display between 2D and 3D mode.
KC3dMode KeyCode = 206
// Contacts special function key.
// Used to launch an address book application.
KCContacts KeyCode = 207
// Calendar special function key.
// Used to launch a calendar application.
KCCalendar KeyCode = 208
// Music special function key.
// Used to launch a music player application.
KCMusic KeyCode = 209
// Calculator special function key.
// Used to launch a calculator application.
KCCalculator KeyCode = 210
// Japanese full-width / half-width key.
KCZenkakuHankaku KeyCode = 211
// Japanese alphanumeric key.
KCEisu KeyCode = 212
// Japanese non-conversion key.
KCMuhenkan KeyCode = 213
// Japanese conversion key.
KCHenkan KeyCode = 214
// Japanese katakana / hiragana key.
KCKatakanaHiragana KeyCode = 215
// Japanese Yen key.
KCYen KeyCode = 216
// Japanese Ro key.
KCRo KeyCode = 217
// Japanese kana key.
KCKana KeyCode = 218
// Assist key.
// Launches the global assist activity. Not delivered to applications.
KCAssist KeyCode = 219
// Brightness Down key.
// Adjusts the screen brightness down.
KCBrightnessDown KeyCode = 220
// Brightness Up key.
// Adjusts the screen brightness up.
KCBrightnessUp KeyCode = 221
// Audio Track key.
// Switches the audio tracks.
KCMediaAudioTrack KeyCode = 222
// Sleep key.
// Puts the device to sleep. Behaves somewhat like {@link #KEYCODE_POWER} but it
// has no effect if the device is already asleep.
KCSleep KeyCode = 223
// Wakeup key.
// Wakes up the device. Behaves somewhat like {@link #KEYCODE_POWER} but it
// has no effect if the device is already awake.
KCWakeup KeyCode = 224
// Pairing key.
// Initiates peripheral pairing mode. Useful for pairing remote control
// devices or game controllers, especially if no other input mode is
// available.
KCPairing KeyCode = 225
// Media Top Menu key.
// Goes to the top of media menu.
KCMediaTopMenu KeyCode = 226
// '11' key.
KC11 KeyCode = 227
// '12' key.
KC12 KeyCode = 228
// Last Channel key.
// Goes to the last viewed channel.
KCLastChannel KeyCode = 229
// TV data service key.
// Displays data services like weather, sports.
KCTvDataService KeyCode = 230
// Voice Assist key.
// Launches the global voice assist activity. Not delivered to applications.
KCVoiceAssist KeyCode = 231
// Radio key.
// Toggles TV service / Radio service.
KCTvRadioService KeyCode = 232
// Teletext key.
// Displays Teletext service.
KCTvTeletext KeyCode = 233
// Number entry key.
// Initiates to enter multi-digit channel nubmber when each digit key is assigned
// for selecting separate channel. Corresponds to Number Entry Mode (0x1D) of CEC
// User Control Code.
KCTvNumberEntry KeyCode = 234
// Analog Terrestrial key.
// Switches to analog terrestrial broadcast service.
KCTvTerrestrialAnalog KeyCode = 235
// Digital Terrestrial key.
// Switches to digital terrestrial broadcast service.
KCTvTerrestrialDigital KeyCode = 236
// Satellite key.
// Switches to digital satellite broadcast service.
KCTvSatellite KeyCode = 237
// BS key.
// Switches to BS digital satellite broadcasting service available in Japan.
KCTvSatelliteBs KeyCode = 238
// CS key.
// Switches to CS digital satellite broadcasting service available in Japan.
KCTvSatelliteCs KeyCode = 239
// BS/CS key.
// Toggles between BS and CS digital satellite services.
KCTvSatelliteService KeyCode = 240
// Toggle Network key.
// Toggles selecting broacast services.
KCTvNetwork KeyCode = 241
// Antenna/Cable key.
// Toggles broadcast input source between antenna and cable.
KCTvAntennaCable KeyCode = 242
// HDMI #1 key.
// Switches to HDMI input #1.
KCTvInputHdmi1 KeyCode = 243
// HDMI #2 key.
// Switches to HDMI input #2.
KCTvInputHdmi2 KeyCode = 244
// HDMI #3 key.
// Switches to HDMI input #3.
KCTvInputHdmi3 KeyCode = 245
// HDMI #4 key.
// Switches to HDMI input #4.
KCTvInputHdmi4 KeyCode = 246
// Composite #1 key.
// Switches to composite video input #1.
KCTvInputComposite1 KeyCode = 247
// Composite #2 key.
// Switches to composite video input #2.
KCTvInputComposite2 KeyCode = 248
// Component #1 key.
// Switches to component video input #1.
KCTvInputComponent1 KeyCode = 249
// Component #2 key.
// Switches to component video input #2.
KCTvInputComponent2 KeyCode = 250
// VGA #1 key.
// Switches to VGA (analog RGB) input #1.
KCTvInputVga1 KeyCode = 251
// Audio description key.
// Toggles audio description off / on.
KCTvAudioDescription KeyCode = 252
// Audio description mixing volume up key.
// Louden audio description volume as compared with normal audio volume.
KCTvAudioDescriptionMixUp KeyCode = 253
// Audio description mixing volume down key.
// Lessen audio description volume as compared with normal audio volume.
KCTvAudioDescriptionMixDown KeyCode = 254
// Zoom mode key.
// Changes Zoom mode (Normal, Full, Zoom, Wide-zoom, etc.)
KCTvZoomMode KeyCode = 255
// Contents menu key.
// Goes to the title list. Corresponds to Contents Menu (0x0B) of CEC User Control
// Code
KCTvContentsMenu KeyCode = 256
// Media context menu key.
// Goes to the context menu of media contents. Corresponds to Media Context-sensitive
// Menu (0x11) of CEC User Control Code.
KCTvMediaContextMenu KeyCode = 257
// Timer programming key.
// Goes to the timer recording menu. Corresponds to Timer Programming (0x54) of
// CEC User Control Code.
KCTvTimerProgramming KeyCode = 258
// Help key.
KCHelp KeyCode = 259
// Navigate to previous key.
// Goes backward by one item in an ordered collection of items.
KCNavigatePrevious KeyCode = 260
// Navigate to next key.
// Advances to the next item in an ordered collection of items.
KCNavigateNext KeyCode = 261
// Navigate in key.
// Activates the item that currently has focus or expands to the next level of a navigation
// hierarchy.
KCNavigateIn KeyCode = 262
// Navigate out key.
// Backs out one level of a navigation hierarchy or collapses the item that currently has
// focus.
KCNavigateOut KeyCode = 263
// Primary stem key for Wear
// Main power/reset button on watch.
KCStemPrimary KeyCode = 264
// Generic stem key 1 for Wear
KCStem1 KeyCode = 265
// Generic stem key 2 for Wear
KCStem2 KeyCode = 266
// Generic stem key 3 for Wear
KCStem3 KeyCode = 267
// Directional Pad Up-Left
KCDPadUpLeft KeyCode = 268
// Directional Pad Down-Left
KCDPadDownLeft KeyCode = 269
// Directional Pad Up-Right
KCDPadUpRight KeyCode = 270
// Directional Pad Down-Right
KCDPadDownRight KeyCode = 271
// Skip forward media key.
KCMediaSkipForward KeyCode = 272
// Skip backward media key.
KCMediaSkipBackward KeyCode = 273
// Step forward media key.
// Steps media forward, one frame at a time.
KCMediaStepForward KeyCode = 274
// Step backward media key.
// Steps media backward, one frame at a time.
KCMediaStepBackward KeyCode = 275
// put device to sleep unless a wakelock is held.
KCSoftSleep KeyCode = 276
// Cut key.
KCCut KeyCode = 277
// Copy key.
KCCopy KeyCode = 278
// Paste key.
KCPaste KeyCode = 279
// Consumed by the system for navigation up
KCSystemNavigationUp KeyCode = 280
// Consumed by the system for navigation down
KCSystemNavigationDown KeyCode = 281
// Consumed by the system for navigation left*/
KCSystemNavigationLeft KeyCode = 282
// Consumed by the system for navigation right
KCSystemNavigationRight KeyCode = 283
// Show all apps
KCAllApps KeyCode = 284
// Refresh key.
KCRefresh KeyCode = 285
)

View File

@@ -1,62 +0,0 @@
package uixt
import (
"encoding/xml"
"fmt"
"regexp"
"strconv"
)
type Attributes struct {
Index int `xml:"index,attr"`
Package string `xml:"package,attr"`
Class string `xml:"class,attr"`
Text string `xml:"text,attr"`
ResourceId string `xml:"resource-id,attr"`
Checkable bool `xml:"checkable,attr"`
Checked bool `xml:"checked,attr"`
Clickable bool `xml:"clickable,attr"`
Enabled bool `xml:"enabled,attr"`
Focusable bool `xml:"focusable,attr"`
Focused bool `xml:"focused,attr"`
LongClickable bool `xml:"long-clickable,attr"`
Password bool `xml:"password,attr"`
Scrollable bool `xml:"scrollable,attr"`
Selected bool `xml:"selected,attr"`
Bounds *Bounds `xml:"bounds,attr"`
Displayed bool `xml:"displayed,attr"`
}
type Hierarchy struct {
XMLName xml.Name `xml:"hierarchy"`
Attributes
Layout []Layout `xml:",any"`
}
type Layout struct {
Attributes
Layout []Layout `xml:",any"`
}
type Bounds struct {
X1, Y1, X2, Y2 int
}
func (b *Bounds) Center() (float64, float64) {
return float64(b.X1+b.X2) / 2, float64(b.Y1+b.Y2) / 2
}
func (b *Bounds) UnmarshalXMLAttr(attr xml.Attr) error {
// 正则表达式用于解析格式为"[x1,y1][x2,y2]"
re := regexp.MustCompile(`\[(\d+),(\d+)]\[(\d+),(\d+)]`)
matches := re.FindStringSubmatch(attr.Value)
if matches == nil {
return fmt.Errorf("bounds format is incorrect")
}
// 转换字符串为整数
b.X1, _ = strconv.Atoi(matches[1])
b.Y1, _ = strconv.Atoi(matches[2])
b.X2, _ = strconv.Atoi(matches[3])
b.Y2, _ = strconv.Atoi(matches[4])
return nil
}

View File

@@ -1,236 +0,0 @@
//go:build localtest
package uixt
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/httprunner/httprunner/v5/pkg/uixt/ai"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
func setupADBDriverExt(t *testing.T) *XTDriver {
device, err := NewAndroidDevice()
require.Nil(t, err)
device.Options.UIA2 = false
device.Options.LogOn = false
driver, err := device.NewDriver()
require.Nil(t, err)
return NewXTDriver(driver,
ai.WithCVService(ai.CVServiceTypeVEDEM))
}
func setupUIA2DriverExt(t *testing.T) *XTDriver {
device, err := NewAndroidDevice()
require.Nil(t, err)
device.Options.UIA2 = true // use uiautomator2 driver
device.Options.LogOn = false
driver, err := device.NewDriver()
require.Nil(t, err)
return NewXTDriver(driver,
ai.WithCVService(ai.CVServiceTypeVEDEM))
}
func TestDevice_Android_GetPackageInfo(t *testing.T) {
driver := setupADBDriverExt(t)
appInfo, err := driver.GetDevice().GetPackageInfo("com.android.settings")
require.Nil(t, err)
t.Log(appInfo)
assert.Equal(t, "com.android.settings", appInfo.Name)
assert.NotEmpty(t, appInfo.AppPath)
assert.NotEmpty(t, appInfo.AppMD5)
}
func TestDevice_Android_GetCurrentWindow(t *testing.T) {
driver := setupADBDriverExt(t)
driver.AppLaunch("com.android.settings")
windowInfo, err := driver.GetDevice().(*AndroidDevice).GetCurrentWindow()
require.Nil(t, err)
assert.Equal(t, "com.android.settings", windowInfo.PackageName)
}
func TestDriver_ADB_Session_TODO(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.InitSession(nil)
require.Nil(t, err)
err = driver.DeleteSession()
assert.Nil(t, err)
}
func TestDriver_ADB_Status_TODO(t *testing.T) {
driver := setupADBDriverExt(t)
status, err := driver.Status()
require.Nil(t, err)
t.Log(status)
}
func TestDriver_ADB_ScreenShot(t *testing.T) {
driver := setupADBDriverExt(t)
screenshot, err := driver.ScreenShot()
assert.Nil(t, err)
path, err := saveScreenShot(screenshot, "1234")
require.Nil(t, err)
defer os.Remove(path)
t.Logf("save screenshot to %s", path)
}
func TestDriver_ADB_Rotation_TODO(t *testing.T) {
driver := setupADBDriverExt(t)
rotation, err := driver.Rotation()
require.Nil(t, err)
t.Logf("x = %d\ty = %d\tz = %d", rotation.X, rotation.Y, rotation.Z)
}
func TestDriver_ADB_DeviceSize(t *testing.T) {
driver := setupADBDriverExt(t)
deviceSize, err := driver.WindowSize()
require.Nil(t, err)
assert.Greater(t, deviceSize.Width, 200)
assert.Greater(t, deviceSize.Height, 200)
}
func TestDriver_ADB_Source(t *testing.T) {
driver := setupADBDriverExt(t)
source, err := driver.Source()
require.Nil(t, err)
assert.Contains(t, source, "<?xml version")
assert.Contains(t, source, "android.widget.TextView")
t.Log(source)
}
func TestDriver_ADB_BatteryInfo_TODO(t *testing.T) {
driver := setupADBDriverExt(t)
batteryInfo, err := driver.BatteryInfo()
require.Nil(t, err)
t.Log(batteryInfo)
}
func TestDriver_ADB_DeviceInfo_TODO(t *testing.T) {
driver := setupADBDriverExt(t)
devInfo, err := driver.DeviceInfo()
require.Nil(t, err)
t.Logf("api version: %s", devInfo.APIVersion)
t.Logf("platform version: %s", devInfo.PlatformVersion)
t.Logf("bluetooth state: %s", devInfo.Bluetooth.State)
}
func TestDriver_ADB_TapXY(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.TapXY(0.4, 0.5)
assert.Nil(t, err)
}
func TestDriver_ADB_TapAbsXY(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.TapAbsXY(100, 300)
assert.Nil(t, err)
}
func TestDriver_ADB_Swipe(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.Swipe(0.5, 0.7, 0.5, 0.5,
option.WithPressDuration(0.5))
assert.Nil(t, err)
}
func TestDriver_ADB_Drag(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.Drag(0.5, 0.7, 0.5, 0.5)
assert.Nil(t, err)
}
func TestDriver_ADB_Input(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.Input("Hi 你好\n",
option.WithIdentifier("test"))
assert.Nil(t, err)
time.Sleep(time.Second * 1)
err = driver.Input("123\n")
assert.Nil(t, err)
}
func TestDriver_ADB_PressBack(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.Back()
assert.Nil(t, err)
}
func TestDriver_ADB_SetRotation_TODO(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.SetRotation(types.Rotation{Z: 270})
assert.Nil(t, err)
}
func TestDriver_ADB_Orientation(t *testing.T) {
driver := setupADBDriverExt(t)
orientation, err := driver.Orientation()
assert.Nil(t, err)
assert.Equal(t, types.OrientationPortrait, orientation)
}
func TestDriver_ADB_AppLaunchTerminate(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.AppLaunch("com.android.settings")
assert.Nil(t, err)
time.Sleep(1 * time.Second)
ok, err := driver.AppTerminate("com.android.settings")
assert.Nil(t, err)
assert.True(t, ok)
}
func TestDriver_ADB_ForegroundInfo(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.AppLaunch("com.android.settings")
assert.Nil(t, err)
app, err := driver.ForegroundInfo()
assert.Nil(t, err)
assert.Equal(t, "com.android.settings", app.PackageName)
}
func TestDriver_ADB_ScreenRecord(t *testing.T) {
driver := setupADBDriverExt(t)
path, err := driver.ScreenRecord(5 * time.Second)
assert.Nil(t, err)
defer os.Remove(path)
t.Log(path)
}
func TestDriver_ADB_Backspace(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.Backspace(1)
assert.Nil(t, err)
}
func TestDriver_UIA2_TapXY(t *testing.T) {
driver := setupUIA2DriverExt(t)
driver.StartCaptureLog("tap_xy")
err := driver.TapXY(0.5, 0.5,
option.WithIdentifier("test"),
option.WithPressDuration(4))
assert.Nil(t, err)
result, _ := driver.StopCaptureLog()
t.Log(result)
}
func TestDriver_UIA2_Swipe(t *testing.T) {
driver := setupUIA2DriverExt(t)
err := driver.Swipe(0.5, 0.7, 0.5, 0.5,
option.WithPressDuration(0.5))
assert.Nil(t, err)
}
func TestDriver_UIA2_Input(t *testing.T) {
driver := setupUIA2DriverExt(t)
err := driver.Input("Hi 你好\n",
option.WithIdentifier("test"))
assert.Nil(t, err)
time.Sleep(time.Second * 1)
err = driver.Input("123\n")
assert.Nil(t, err)
}

View File

@@ -1,75 +0,0 @@
package uixt
import (
"bytes"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
type BrowserDevice struct {
Options *option.BrowserDeviceOptions
}
func NewBrowserDevice(opts ...option.BrowserDeviceOption) (device *BrowserDevice, err error) {
options := option.NewBrowserDeviceOptions(opts...)
if options.BrowserID == "" {
browserInfo, err := CreateBrowser(3600)
if err != nil {
log.Error().Err(err).Msg("failed to create browser")
return nil, err
}
options.BrowserID = browserInfo.ContextId
}
device = &BrowserDevice{
Options: options,
}
log.Info().Str("browserID", device.Options.BrowserID).Msg("init browser device")
return device, nil
}
func (dev *BrowserDevice) UUID() string {
return dev.Options.BrowserID
}
func (dev *BrowserDevice) Setup() error {
return nil
}
func (dev *BrowserDevice) LogEnabled() bool {
return dev.Options.LogOn
}
func (dev *BrowserDevice) Teardown() error {
return nil
}
func (dev *BrowserDevice) Install(appPath string, opts ...option.InstallOption) error {
return errors.New("not support")
}
func (dev *BrowserDevice) Uninstall(packageName string) error {
return errors.New("not support")
}
func (dev *BrowserDevice) GetPackageInfo(packageName string) (types.AppInfo, error) {
return types.AppInfo{}, errors.New("not support")
}
func (dev *BrowserDevice) NewDriver() (driver IDriver, err error) {
// var driver WebDriver
driver, err = NewBrowserDriver(dev)
if err != nil {
return nil, err
}
return driver, nil
}
func (dev *BrowserDevice) ScreenShot() (*bytes.Buffer, error) {
return nil, errors.New("not support")
}

View File

@@ -1,638 +0,0 @@
package uixt
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"time"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
const BROWSER_LOCAL_ADDRESS = "localhost:8093"
type WebAgentResponse struct {
Code int `json:"code"`
Message string `json:"msg"`
Data interface{} `json:"data"`
Result interface{} `json:"result"`
}
type CreateBrowserResponse struct {
Code int `json:"code"`
Message string `json:"msg"`
Data BrowserInfo `json:"data"`
}
type BrowserDriver struct {
urlPrefix *url.URL
sessionId string
}
type BrowserInfo struct {
ContextId string `json:"context_id"`
}
func CreateBrowser(timeout int) (browserInfo *BrowserInfo, err error) {
data := map[string]interface{}{
"timeout": timeout,
}
var bsJSON []byte = nil
if bsJSON, err = json.Marshal(data); err != nil {
return nil, err
}
rawURL := "http://" + BROWSER_LOCAL_ADDRESS + "/api/v1/create_browser"
req, err := http.NewRequest(http.MethodPost, rawURL, bytes.NewBuffer(bsJSON))
req.Header.Set("Content-Type", "application/json")
if err != nil {
return nil, err
}
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
rawResp, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, errors.New(resp.Status)
}
var result CreateBrowserResponse
if err = json.Unmarshal(rawResp, &result); err != nil {
return nil, err
}
if result.Code != 0 {
return nil, errors.New(result.Message)
}
return &result.Data, nil
}
func NewBrowserDriver(device *BrowserDevice) (driver *BrowserDriver, err error) {
log.Info().Msg("init NewBrowserDriver driver")
driver = new(BrowserDriver)
driver.urlPrefix = &url.URL{}
driver.urlPrefix.Host = BROWSER_LOCAL_ADDRESS
driver.urlPrefix.Scheme = "http"
driver.sessionId = device.UUID()
return driver, nil
}
func (wd *BrowserDriver) Drag(fromX, fromY, toX, toY float64, options ...option.ActionOption) (err error) {
data := map[string]interface{}{
"from_x": fromX,
"from_y": fromY,
"to_x": toX,
"to_y": toY,
}
actionOptions := option.NewActionOptions(options...)
if actionOptions.Duration > 0 {
data["duration"] = actionOptions.Duration
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/drag")
return
}
func (wd *BrowserDriver) AppLaunch(packageName string) (err error) {
data := map[string]interface{}{
"url": packageName,
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/page_launch")
return
}
func (wd *BrowserDriver) DeleteSession() (err error) {
url := wd.concatURL("context", wd.sessionId)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
panic(err)
}
client := &http.Client{
Timeout: 60 * time.Second, // 设置超时时间为5秒
}
resp, err := client.Do(req)
if err != nil {
return err
}
rawResp, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return errors.New(resp.Status)
}
var result CreateBrowserResponse
if err = json.Unmarshal(rawResp, &result); err != nil {
return err
}
if result.Code != 0 {
return errors.New(result.Message)
}
return nil
}
func (wd *BrowserDriver) Scroll(delta int) (err error) {
data := map[string]interface{}{
"delta": delta,
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/scroll")
return err
}
func (wd *BrowserDriver) CreateNetListener() (*websocket.Conn, error) {
webSocketUrl := "ws://localhost:8093/websocket_net_listen"
c, _, err := websocket.DefaultDialer.Dial(webSocketUrl, nil)
if err != nil {
return nil, err
}
// 发送消息
initMessage := fmt.Sprintf(`{
"type":"create_net_listener",
"context_id":"%v"
}`, wd.sessionId)
err = c.WriteMessage(websocket.TextMessage, []byte(initMessage))
return c, err
}
func (wd *BrowserDriver) ClosePage(pageIndex int) (err error) {
data := map[string]interface{}{
"page_index": pageIndex,
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/page_close")
return err
}
func (wd *BrowserDriver) HoverBySelector(selector string, options ...option.ActionOption) (err error) {
data := map[string]interface{}{
"selector": selector,
}
actionOptions := option.NewActionOptions(options...)
if actionOptions.Index > 0 {
data["element_index"] = actionOptions.Index
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/hover")
return err
}
func (wd *BrowserDriver) tapBySelector(selector string, options ...option.ActionOption) (err error) {
data := map[string]interface{}{
"selector": selector,
}
actionOptions := option.NewActionOptions(options...)
if actionOptions.Index > 0 {
data["element_index"] = actionOptions.Index
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/tap")
return err
}
func (wd *BrowserDriver) RightClick(x, y float64) (err error) {
data := map[string]interface{}{
"x": x,
"y": y,
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/right_click")
return err
}
func (wd *BrowserDriver) RightclickbySelector(selector string, options ...option.ActionOption) (err error) {
data := map[string]interface{}{
"selector": selector,
}
actionOptions := option.NewActionOptions(options...)
if actionOptions.Index > 0 {
data["element_index"] = actionOptions.Index
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/right_click")
return err
}
func (wd *BrowserDriver) GetElementTextBySelector(selector string, options ...option.ActionOption) (text string, err error) {
actionOptions := option.NewActionOptions(options...)
uri := "ui/element_text?selector=" + selector
if actionOptions.Index > 0 {
uri = uri + "&element_index=" + fmt.Sprintf("%v", actionOptions.Index)
}
resp, err := wd.HttpGet(http.MethodGet, wd.sessionId, uri)
if err != nil {
return "", err
}
data := resp.Data.(map[string]interface{})
return data["text"].(string), nil
}
func (wd *BrowserDriver) GetPageUrl(options ...option.ActionOption) (text string, err error) {
uri := "ui/page_url"
actionOptions := option.NewActionOptions(options...)
if actionOptions.Index > 0 {
uri = uri + "?page_index=" + fmt.Sprintf("%v", actionOptions.Index)
}
resp, err := wd.HttpGet(http.MethodGet, wd.sessionId, uri)
if err != nil {
return "", err
}
data := resp.Data.(map[string]interface{})
return data["url"].(string), nil
}
func (wd *BrowserDriver) IsElementExistBySelector(selector string) (bool, error) {
resp, err := wd.HttpGet(wd.sessionId, "ui/element_exist", "?selector=", selector)
if err != nil {
return false, err
}
data := resp.Data.(map[string]interface{})
return data["exist"].(bool), nil
}
func (wd *BrowserDriver) Hover(x, y float64) (err error) {
data := map[string]interface{}{
"x": x,
"y": y,
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/hover")
return err
}
func (wd *BrowserDriver) Input(text string, option ...option.ActionOption) (err error) {
data := map[string]interface{}{
"text": text,
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/input")
return err
}
// Source Return application elements tree
func (wd *BrowserDriver) Source(srcOpt ...option.SourceOption) (string, error) {
resp, err := wd.HttpGet(http.MethodGet, wd.sessionId, "stub/source")
if err != nil {
return "", err
}
jsonData, err := json.Marshal(resp.Data)
if err != nil {
return "", err
}
return string(jsonData), err
}
func (wd *BrowserDriver) ScreenShot(options ...option.ActionOption) (*bytes.Buffer, error) {
resp, err := wd.HttpGet(http.MethodGet, wd.sessionId, "screenshot")
if err != nil {
return nil, err
}
data := resp.Data.(map[string]interface{})
screenshotBase64 := data["screenshot"].(string)
screenRaw, err := base64.StdEncoding.DecodeString(screenshotBase64)
res := bytes.NewBuffer(screenRaw)
return res, err
}
func (wd *BrowserDriver) HttpPOST(data interface{}, pathElem ...string) (response *WebAgentResponse, err error) {
var bsJSON []byte = nil
if data != nil {
if bsJSON, err = json.Marshal(data); err != nil {
return nil, err
}
}
return wd.httpRequest(http.MethodPost, wd.concatURL(pathElem...), bsJSON)
}
func (wd *BrowserDriver) HttpGet(data interface{}, pathElem ...string) (response *WebAgentResponse, err error) {
return wd.httpRequest(http.MethodGet, wd.concatURL(pathElem...), nil)
}
func (wd *BrowserDriver) concatURL(elem ...string) string {
tmp, _ := url.Parse(wd.urlPrefix.String())
commonPath := path.Join(append([]string{wd.urlPrefix.Path}, "api/v1/")...)
tmp.Path = path.Join(append([]string{commonPath}, elem...)...)
return tmp.String()
}
func (wd *BrowserDriver) httpRequest(method string, rawURL string, rawBody []byte, disableRetry ...bool) (response *WebAgentResponse, err error) {
req, err := http.NewRequest(method, rawURL, bytes.NewBuffer(rawBody))
req.Header.Set("Content-Type", "application/json")
if err != nil {
return nil, err
}
// 新建http client
client := &http.Client{
Timeout: 60 * time.Second, // 设置超时时间为5秒
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
rawResp, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, errors.New(resp.Status)
}
// 将结果解析为 JSON
var result WebAgentResponse
if err = json.Unmarshal(rawResp, &result); err != nil {
return nil, err
}
if result.Code != 0 {
log.Info().Msgf("%v", result.Message)
return nil, errors.New(result.Message)
}
if err != nil {
return nil, err
}
return &result, err
}
func (wd *BrowserDriver) Status() (deviceStatus types.DeviceStatus, err error) {
log.Warn().Msg("Status not implemented in ADBDriver")
return
}
func (wd *BrowserDriver) DeviceInfo() (deviceInfo types.DeviceInfo, err error) {
log.Warn().Msg("DeviceInfo not implemented in ADBDriver")
return
}
func (wd *BrowserDriver) BatteryInfo() (batteryInfo types.BatteryInfo, err error) {
log.Warn().Msg("BatteryInfo not implemented in ADBDriver")
return
}
func (wd *BrowserDriver) WindowSize() (types.Size, error) {
resp, err := wd.HttpGet(http.MethodGet, wd.sessionId, "window_size")
if err != nil {
return types.Size{}, err
}
data := resp.Data.(map[string]interface{})
width := data["width"]
height := data["height"]
return types.Size{
Width: int(width.(float64)),
Height: int(height.(float64)),
}, nil
}
func (wd *BrowserDriver) Screen() (Screen, error) {
return Screen{}, errors.New("not support")
}
func (wd *BrowserDriver) Scale() (float64, error) {
return 0, errors.New("not support")
}
// GetTimestamp returns the timestamp of the mobile device
func (wd *BrowserDriver) GetTimestamp() (timestamp int64, err error) {
return 0, errors.New("not support")
}
// Homescreen Forces the device under test to switch to the home screen
func (wd *BrowserDriver) Homescreen() error {
return errors.New("not support")
}
func (wd *BrowserDriver) Unlock() (err error) {
return errors.New("not support")
}
// AppTerminate Terminate an application with the given package name.
// Either `true` if the app has been successfully terminated or `false` if it was not running
func (wd *BrowserDriver) AppTerminate(packageName string) (bool, error) {
return false, errors.New("not support")
}
// AssertForegroundApp returns nil if the given package and activity are in foreground
func (wd *BrowserDriver) AssertForegroundApp(packageName string, activityType ...string) error {
return errors.New("not support")
}
func (wd *BrowserDriver) Back() error {
return errors.New("not support")
}
func (wd *BrowserDriver) AppClear(packageName string) error {
return errors.New("not support")
}
func (wd *BrowserDriver) ClearImages() error {
return errors.New("not support")
}
func (wd *BrowserDriver) PushImage(localPath string) error {
return errors.New("not support")
}
func (wd *BrowserDriver) Orientation() (orientation types.Orientation, err error) {
log.Warn().Msg("Orientation not implemented in ADBDriver")
return
}
// Tap Sends a tap event at the coordinate.
func (wd *BrowserDriver) Tap(x, y int, options ...option.ActionOption) error {
return errors.New("not support")
}
func (wd *BrowserDriver) TapFloat(x, y float64, options ...option.ActionOption) error {
actionOptions := option.NewActionOptions(options...)
duration := 0.1
if actionOptions.Duration > 0 {
duration = actionOptions.Duration
}
data := map[string]interface{}{
"x": x,
"y": y,
"duration": duration,
}
_, err := wd.HttpPOST(data, wd.sessionId, "ui/tap")
return err
}
// DoubleTap Sends a double tap event at the coordinate.
func (wd *BrowserDriver) DoubleTap(x, y float64, options ...option.ActionOption) error {
data := map[string]interface{}{
"x": x,
"y": y,
}
_, err := wd.HttpPOST(data, wd.sessionId, "ui/double_tap")
return err
}
func (wd *BrowserDriver) UploadFile(x, y float64, FileUrl, FileFormat string) (err error) {
data := map[string]interface{}{
"x": x,
"y": y,
"file_url": FileUrl,
"file_format": FileFormat,
}
_, err = wd.HttpPOST(data, wd.sessionId, "ui/upload")
return err
}
// TouchAndHold Initiates a long-press gesture at the coordinate, holding for the specified duration.
//
// second: The default value is 1
func (wd *BrowserDriver) TouchAndHold(x, y float64, options ...option.ActionOption) error {
return errors.New("not support")
}
// Swipe works like Drag, but `pressForDuration` value is 0
func (wd *BrowserDriver) Swipe(fromX, fromY, toX, toY float64, options ...option.ActionOption) error {
return errors.New("not support")
}
func (wd *BrowserDriver) SwipeFloat(fromX, fromY, toX, toY float64, options ...option.ActionOption) error {
return errors.New("not support")
}
func (wd *BrowserDriver) SetIme(ime string) error {
return errors.New("not support")
}
// SendKeys Types a string into active element. There must be element with keyboard focus,
// otherwise an error is raised.
// WithFrequency option can be used to set frequency of typing (letters per sec). The default value is 60
func (wd *BrowserDriver) SendKeys(text string, options ...option.ActionOption) error {
return errors.New("not support")
}
func (wd *BrowserDriver) Clear(packageName string) error {
return errors.New("not support")
}
func (wd *BrowserDriver) Setup() error {
return nil
}
func (wd *BrowserDriver) GetDevice() IDevice {
return nil
}
func (wd *BrowserDriver) ForegroundInfo() (app types.AppInfo, err error) {
return
}
// PressBack Presses the back button
func (wd *BrowserDriver) PressBack(options ...option.ActionOption) error {
_, err := wd.HttpPOST(map[string]interface{}{}, wd.sessionId, "ui/back")
return err
}
func (wd *BrowserDriver) PressKeyCode(keyCode KeyCode) (err error) {
return errors.New("not support")
}
func (wd *BrowserDriver) Backspace(count int, options ...option.ActionOption) (err error) {
return errors.New("not support")
}
func (wd *BrowserDriver) LogoutNoneUI(packageName string) error {
return errors.New("not support")
}
func (wd *BrowserDriver) TapByText(text string, options ...option.ActionOption) error {
return errors.New("not support")
}
// AccessibleSource Return application elements accessibility tree
func (wd *BrowserDriver) AccessibleSource() (string, error) {
return "", errors.New("not support")
}
// HealthCheck Health check might modify simulator state so it should only be called in-between testing sessions
//
// Checks health of XCTest by:
// 1) Querying application for some elements,
// 2) Triggering some device events.
func (wd *BrowserDriver) HealthCheck() error {
return errors.New("not support")
}
func (wd *BrowserDriver) GetAppiumSettings() (map[string]interface{}, error) {
return nil, errors.New("not support")
}
func (wd *BrowserDriver) SetAppiumSettings(settings map[string]interface{}) (map[string]interface{}, error) {
return nil, errors.New("not support")
}
func (wd *BrowserDriver) IsHealthy() (bool, error) {
return false, errors.New("not support")
}
// triggers the log capture and returns the log entries
func (wd *BrowserDriver) StartCaptureLog(identifier ...string) (err error) {
return errors.New("not support")
}
func (wd *BrowserDriver) StopCaptureLog() (result interface{}, err error) {
return nil, errors.New("not support")
}
func (wd *BrowserDriver) RecordScreen(folderPath string, duration time.Duration) (videoPath string, err error) {
return "", errors.New("not support")
}
func (wd *BrowserDriver) TearDown() error {
return nil
}
func (wd *BrowserDriver) InitSession(capabilities option.Capabilities) error {
return errors.New("not support")
}
func (wd *BrowserDriver) GetSession() *DriverSession {
return nil
}
func (wd *BrowserDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) {
return
}
func (wd *BrowserDriver) Rotation() (rotation types.Rotation, err error) {
return
}
func (wd *BrowserDriver) SetRotation(rotation types.Rotation) error {
return errors.New("not support")
}
func (wd *BrowserDriver) Home() error {
return errors.New("not support")
}
func (wd *BrowserDriver) TapXY(x, y float64, opts ...option.ActionOption) error {
return errors.New("not support")
}
func (wd *BrowserDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
return wd.TapFloat(x, y, opts...)
}

View File

@@ -1,60 +0,0 @@
//go:build localtest
package demo
import (
"testing"
"time"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/pkg/uixt"
"github.com/httprunner/httprunner/v5/pkg/uixt/ai"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
)
func TestIOSDemo(t *testing.T) {
device, err := uixt.NewIOSDevice(
option.WithWDAPort(8700),
option.WithWDAMjpegPort(8800),
option.WithResetHomeOnStartup(false), // not reset home on startup
)
if err != nil {
t.Fatal(err)
}
driver, err := device.NewDriver()
if err != nil {
t.Fatal(err)
}
driverExt := uixt.NewXTDriver(driver,
ai.WithCVService(ai.CVServiceTypeVEDEM),
)
// release session
defer func() {
driverExt.DeleteSession()
}()
// 持续监测手机屏幕,直到出现青少年模式弹窗后,点击「我知道了」
for {
// take screenshot and get screen texts by OCR
ocrTexts, err := driverExt.GetScreenTexts()
if err != nil {
log.Error().Err(err).Msg("OCR GetTexts failed")
t.Fatal(err)
}
points, err := ocrTexts.FindTexts([]string{"青少年模式", "我知道了"})
if err != nil {
time.Sleep(1 * time.Second)
continue
}
point := points[1].Center()
err = driverExt.TapAbsXY(point.X, point.Y)
if err != nil {
t.Fatal(err)
}
}
}

View File

@@ -1,24 +0,0 @@
package uixt
import (
"bytes"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
// current implemeted device: IOSDevice, AndroidDevice, HarmonyDevice
type IDevice interface {
UUID() string
NewDriver() (driver IDriver, err error)
Setup() error
Teardown() error
Install(appPath string, opts ...option.InstallOption) error
Uninstall(packageName string) error
GetPackageInfo(packageName string) (types.AppInfo, error)
ScreenShot() (*bytes.Buffer, error)
// TODO: remove?
LogEnabled() bool
}

View File

@@ -1,99 +0,0 @@
package uixt
import (
"bytes"
_ "image/gif"
_ "image/png"
"time"
"github.com/httprunner/httprunner/v5/pkg/uixt/ai"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
var (
_ IDriver = (*ADBDriver)(nil)
_ IDriver = (*UIA2Driver)(nil)
_ IDriver = (*WDADriver)(nil)
_ IDriver = (*HDCDriver)(nil)
_ IDriver = (*BrowserDriver)(nil)
)
// current implemeted driver: ADBDriver, UIA2Driver, WDADriver, HDCDriver
type IDriver interface {
GetDevice() IDevice
Setup() error
TearDown() error
// session
InitSession(capabilities option.Capabilities) error
GetSession() *DriverSession
DeleteSession() error
// device info and status
Status() (types.DeviceStatus, error)
DeviceInfo() (types.DeviceInfo, error)
BatteryInfo() (types.BatteryInfo, error)
ForegroundInfo() (app types.AppInfo, err error)
WindowSize() (types.Size, error)
ScreenShot(opts ...option.ActionOption) (*bytes.Buffer, error)
ScreenRecord(duration time.Duration) (videoPath string, err error)
Source(srcOpt ...option.SourceOption) (string, error)
Orientation() (orientation types.Orientation, err error)
Rotation() (rotation types.Rotation, err error)
// config
SetRotation(rotation types.Rotation) error
SetIme(ime string) error
// actions
Home() error
Unlock() error
Back() error
// tap
TapXY(x, y float64, opts ...option.ActionOption) error // by percentage or absolute coordinate
TapAbsXY(x, y float64, opts ...option.ActionOption) error // by absolute coordinate
DoubleTap(x, y float64, opts ...option.ActionOption) error // by absolute coordinate
TouchAndHold(x, y float64, opts ...option.ActionOption) error
// swipe
Drag(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error
Swipe(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error // by percentage
// input
Input(text string, opts ...option.ActionOption) error
Backspace(count int, opts ...option.ActionOption) error
// app related
AppLaunch(packageName string) error
AppTerminate(packageName string) (bool, error)
AppClear(packageName string) error
// image related
PushImage(localPath string) error
ClearImages() error
// triggers the log capture and returns the log entries
StartCaptureLog(identifier ...string) error
StopCaptureLog() (result interface{}, err error)
}
func NewXTDriver(driver IDriver, opts ...ai.AIServiceOption) *XTDriver {
services := ai.NewAIService(opts...)
driverExt := &XTDriver{
IDriver: driver,
CVService: services.ICVService,
LLMService: services.ILLMService,
screenResults: make([]*ScreenResult, 0),
}
return driverExt
}
// XTDriver = IDriver + AI
type XTDriver struct {
IDriver
CVService ai.ICVService // OCR/CV
LLMService ai.ILLMService // LLM
// cache screenshot results
screenResults []*ScreenResult
}

View File

@@ -1,290 +0,0 @@
package uixt
import (
"encoding/json"
"fmt"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
)
type ActionMethod string
const (
ACTION_LOG ActionMethod = "log"
ACTION_AppInstall ActionMethod = "install"
ACTION_AppUninstall ActionMethod = "uninstall"
ACTION_AppClear ActionMethod = "app_clear"
ACTION_AppStart ActionMethod = "app_start"
ACTION_AppLaunch ActionMethod = "app_launch" // 启动 app 并堵塞等待 app 首屏加载完成
ACTION_AppTerminate ActionMethod = "app_terminate"
ACTION_AppStop ActionMethod = "app_stop"
ACTION_ScreenShot ActionMethod = "screenshot"
ACTION_Sleep ActionMethod = "sleep"
ACTION_SleepMS ActionMethod = "sleep_ms"
ACTION_SleepRandom ActionMethod = "sleep_random"
ACTION_SetIme ActionMethod = "set_ime"
ACTION_GetSource ActionMethod = "get_source"
ACTION_GetForegroundApp ActionMethod = "get_foreground_app"
ACTION_CallFunction ActionMethod = "call_function"
// UI handling
ACTION_Home ActionMethod = "home"
ACTION_TapXY ActionMethod = "tap_xy"
ACTION_TapAbsXY ActionMethod = "tap_abs_xy"
ACTION_TapByOCR ActionMethod = "tap_ocr"
ACTION_TapByCV ActionMethod = "tap_cv"
ACTION_DoubleTapXY ActionMethod = "double_tap_xy"
ACTION_Swipe ActionMethod = "swipe"
ACTION_Input ActionMethod = "input"
ACTION_Back ActionMethod = "back"
ACTION_KeyCode ActionMethod = "keycode"
// custom actions
ACTION_SwipeToTapApp ActionMethod = "swipe_to_tap_app" // swipe left & right to find app and tap
ACTION_SwipeToTapText ActionMethod = "swipe_to_tap_text" // swipe up & down to find text and tap
ACTION_SwipeToTapTexts ActionMethod = "swipe_to_tap_texts" // swipe up & down to find text and tap
ACTION_ClosePopups ActionMethod = "close_popups"
ACTION_EndToEndDelay ActionMethod = "live_e2e"
ACTION_InstallApp ActionMethod = "install_app"
ACTION_UninstallApp ActionMethod = "uninstall_app"
ACTION_DownloadApp ActionMethod = "download_app"
)
const (
// UI validation
// selectors
SelectorName string = "ui_name"
SelectorLabel string = "ui_label"
SelectorOCR string = "ui_ocr"
SelectorImage string = "ui_image"
SelectorForegroundApp string = "ui_foreground_app"
// assertions
AssertionEqual string = "equal"
AssertionNotEqual string = "not_equal"
AssertionExists string = "exists"
AssertionNotExists string = "not_exists"
)
type MobileAction struct {
Method ActionMethod `json:"method,omitempty" yaml:"method,omitempty"`
Params interface{} `json:"params,omitempty" yaml:"params,omitempty"`
Fn func() `json:"-" yaml:"-"` // only used for function action, not serialized
Options *option.ActionOptions `json:"options,omitempty" yaml:"options,omitempty"`
option.ActionOptions
}
func (ma MobileAction) GetOptions() []option.ActionOption {
var actionOptionList []option.ActionOption
// Notice: merge options from ma.Options and ma.ActionOptions
if ma.Options != nil {
actionOptionList = append(actionOptionList, ma.Options.Options()...)
}
actionOptionList = append(actionOptionList, ma.ActionOptions.Options()...)
return actionOptionList
}
func (dExt *XTDriver) DoAction(action MobileAction) (err error) {
actionStartTime := time.Now()
defer func() {
var logger *zerolog.Event
if err != nil {
logger = log.Error().Bool("success", false).Err(err)
} else {
logger = log.Debug().Bool("success", true)
}
logger = logger.
Str("method", string(action.Method)).
Interface("params", action.Params).
Int64("elapsed(ms)", time.Since(actionStartTime).Milliseconds())
logger.Msg("exec uixt action")
}()
switch action.Method {
case ACTION_AppInstall:
if app, ok := action.Params.(string); ok {
if err = dExt.GetDevice().Install(app,
option.WithRetryTimes(action.MaxRetryTimes)); err != nil {
return errors.Wrap(err, "failed to install app")
}
}
case ACTION_AppUninstall:
if packageName, ok := action.Params.(string); ok {
if err = dExt.GetDevice().Uninstall(packageName); err != nil {
return errors.Wrap(err, "failed to uninstall app")
}
}
case ACTION_AppClear:
if packageName, ok := action.Params.(string); ok {
if err = dExt.AppClear(packageName); err != nil {
return errors.Wrap(err, "failed to clear app")
}
}
case ACTION_AppLaunch:
if bundleId, ok := action.Params.(string); ok {
return dExt.AppLaunch(bundleId)
}
return fmt.Errorf("invalid %s params, should be bundleId(string), got %v",
ACTION_AppLaunch, action.Params)
case ACTION_SwipeToTapApp:
if appName, ok := action.Params.(string); ok {
return dExt.SwipeToTapApp(appName, action.GetOptions()...)
}
return fmt.Errorf("invalid %s params, should be app name(string), got %v",
ACTION_SwipeToTapApp, action.Params)
case ACTION_SwipeToTapText:
if text, ok := action.Params.(string); ok {
return dExt.SwipeToTapTexts([]string{text}, action.GetOptions()...)
}
return fmt.Errorf("invalid %s params, should be app text(string), got %v",
ACTION_SwipeToTapText, action.Params)
case ACTION_SwipeToTapTexts:
if texts, ok := action.Params.([]string); ok {
return dExt.SwipeToTapTexts(texts, action.GetOptions()...)
}
if texts, err := builtin.ConvertToStringSlice(action.Params); err == nil {
return dExt.SwipeToTapTexts(texts, action.GetOptions()...)
}
return fmt.Errorf("invalid %s params: %v", ACTION_SwipeToTapTexts, action.Params)
case ACTION_AppTerminate:
if bundleId, ok := action.Params.(string); ok {
success, err := dExt.AppTerminate(bundleId)
if err != nil {
return errors.Wrap(err, "failed to terminate app")
}
if !success {
log.Warn().Str("bundleId", bundleId).Msg("app was not running")
}
return nil
}
return fmt.Errorf("app_terminate params should be bundleId(string), got %v", action.Params)
case ACTION_Home:
return dExt.Home()
case ACTION_SetIme:
if ime, ok := action.Params.(string); ok {
err = dExt.SetIme(ime)
if err != nil {
return errors.Wrap(err, "failed to set ime")
}
return nil
}
case ACTION_GetSource:
if packageName, ok := action.Params.(string); ok {
_, err = dExt.Source(option.WithProcessName(packageName))
if err != nil {
return errors.Wrap(err, "failed to set ime")
}
return nil
}
case ACTION_TapXY:
if params, err := builtin.ConvertToFloat64Slice(action.Params); err == nil {
// relative x,y of window size: [0.5, 0.5]
if len(params) != 2 {
return fmt.Errorf("invalid tap location params: %v", params)
}
x, y := params[0], params[1]
return dExt.TapXY(x, y, action.GetOptions()...)
}
return fmt.Errorf("invalid %s params: %v", ACTION_TapXY, action.Params)
case ACTION_TapAbsXY:
if params, err := builtin.ConvertToFloat64Slice(action.Params); err == nil {
// absolute coordinates x,y of window size: [100, 300]
if len(params) != 2 {
return fmt.Errorf("invalid tap location params: %v", params)
}
x, y := params[0], params[1]
return dExt.TapAbsXY(x, y, action.GetOptions()...)
}
return fmt.Errorf("invalid %s params: %v", ACTION_TapAbsXY, action.Params)
case ACTION_TapByOCR:
if ocrText, ok := action.Params.(string); ok {
return dExt.TapByOCR(ocrText, action.GetOptions()...)
}
return fmt.Errorf("invalid %s params: %v", ACTION_TapByOCR, action.Params)
case ACTION_TapByCV:
actionOptions := option.NewActionOptions(action.GetOptions()...)
if len(actionOptions.ScreenShotWithUITypes) > 0 {
return dExt.TapByCV(action.GetOptions()...)
}
return fmt.Errorf("invalid %s params: %v", ACTION_TapByCV, action.Params)
case ACTION_DoubleTapXY:
if params, err := builtin.ConvertToFloat64Slice(action.Params); err == nil {
// relative x,y of window size: [0.5, 0.5]
if len(params) != 2 {
return fmt.Errorf("invalid tap location params: %v", params)
}
x, y := params[0], params[1]
return dExt.DoubleTap(x, y)
}
return fmt.Errorf("invalid %s params: %v", ACTION_DoubleTapXY, action.Params)
case ACTION_Swipe:
params := action.Params
swipeAction := prepareSwipeAction(dExt, params, action.GetOptions()...)
return swipeAction(dExt)
case ACTION_Input:
// input text on current active element
// append \n to send text with enter
// send \b\b\b to delete 3 chars
param := fmt.Sprintf("%v", action.Params)
return dExt.Input(param)
case ACTION_Back:
return dExt.Back()
case ACTION_Sleep:
if param, ok := action.Params.(json.Number); ok {
seconds, _ := param.Float64()
time.Sleep(time.Duration(seconds*1000) * time.Millisecond)
return nil
} else if param, ok := action.Params.(float64); ok {
time.Sleep(time.Duration(param*1000) * time.Millisecond)
return nil
} else if param, ok := action.Params.(int64); ok {
time.Sleep(time.Duration(param) * time.Second)
return nil
} else if sd, ok := action.Params.(SleepConfig); ok {
sleepStrict(sd.StartTime, int64(sd.Seconds*1000))
return nil
}
return fmt.Errorf("invalid sleep params: %v(%T)", action.Params, action.Params)
case ACTION_SleepMS:
if param, ok := action.Params.(json.Number); ok {
milliseconds, _ := param.Int64()
time.Sleep(time.Duration(milliseconds) * time.Millisecond)
return nil
} else if param, ok := action.Params.(int64); ok {
time.Sleep(time.Duration(param) * time.Millisecond)
return nil
} else if sd, ok := action.Params.(SleepConfig); ok {
sleepStrict(sd.StartTime, sd.Milliseconds)
return nil
}
return fmt.Errorf("invalid sleep ms params: %v(%T)", action.Params, action.Params)
case ACTION_SleepRandom:
if params, err := builtin.ConvertToFloat64Slice(action.Params); err == nil {
sleepStrict(time.Now(), getSimulationDuration(params))
return nil
}
return fmt.Errorf("invalid sleep random params: %v(%T)", action.Params, action.Params)
case ACTION_ScreenShot:
// take screenshot
log.Info().Msg("take screenshot for current screen")
_, err := dExt.GetScreenResult(action.GetScreenOptions()...)
return err
case ACTION_ClosePopups:
return dExt.ClosePopupsHandler()
case ACTION_CallFunction:
fn := action.Fn
fn()
return nil
default:
log.Warn().Str("action", string(action.Method)).Msg("action not implemented")
return errors.Wrapf(code.InvalidCaseError,
"UI action %v not implemented", action.Method)
}
return nil
}

View File

@@ -1,341 +0,0 @@
package driver_ext
import (
"fmt"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/pkg/uixt"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
type StubAndroidDriver struct {
*uixt.ADBDriver
seq int
timeout time.Duration
douyinUrlPrefix string
douyinLiteUrlPrefix string
}
const (
StubSocketName = "com.bytest.device"
AndroidDouyinPort = 32316
AndroidDouyinLitePort = 32792
)
type AppLoginInfo struct {
Did string `json:"did,omitempty" yaml:"did,omitempty"`
Uid string `json:"uid,omitempty" yaml:"uid,omitempty"`
IsLogin bool `json:"is_login,omitempty" yaml:"is_login,omitempty"`
}
func NewStubAndroidDriver(dev *uixt.AndroidDevice) (*StubAndroidDriver, error) {
adbDriver, err := uixt.NewADBDriver(dev)
if err != nil {
return nil, err
}
driver := &StubAndroidDriver{
timeout: 10 * time.Second,
ADBDriver: adbDriver,
}
// setup driver
if err = driver.Setup(); err != nil {
return nil, err
}
return driver, nil
}
func (sad *StubAndroidDriver) GetDriver() uixt.IDriver {
return sad.ADBDriver
}
func (sad *StubAndroidDriver) Setup() error {
socketLocalPort, err := sad.Device.Forward(StubSocketName)
if err != nil {
return errors.Wrap(code.DeviceConnectionError,
fmt.Sprintf("forward port %d->%s failed: %v",
socketLocalPort, StubSocketName, err))
}
douyinLocalPort, err := sad.Device.Forward(AndroidDouyinPort)
if err != nil {
return errors.Wrap(code.DeviceConnectionError,
fmt.Sprintf("forward port %d->%d failed: %v",
douyinLocalPort, AndroidDouyinPort, err))
}
sad.douyinUrlPrefix = fmt.Sprintf("http://127.0.0.1:%d", douyinLocalPort)
douyinLiteLocalPort, err := sad.Device.Forward(AndroidDouyinLitePort)
if err != nil {
return errors.Wrap(code.DeviceConnectionError,
fmt.Sprintf("forward port %d->%d failed: %v",
douyinLiteLocalPort, AndroidDouyinLitePort, err))
}
sad.douyinLiteUrlPrefix = fmt.Sprintf("http://127.0.0.1:%d", douyinLiteLocalPort)
return nil
}
func (sad *StubAndroidDriver) sendCommand(packageName string, cmdType string, params map[string]interface{}) (
interface{}, error) {
sad.seq++
packet := map[string]interface{}{
"Seq": sad.seq,
"Cmd": cmdType,
"v": "",
}
for key, value := range params {
if key == "Cmd" || key == "Seq" {
return "", errors.New("params cannot be Cmd or Seq")
}
packet[key] = value
}
data, err := json.Marshal(packet)
if err != nil {
return nil, err
}
res, err := sad.Device.RunStubCommand(append(data, '\n'), packageName)
if err != nil {
return nil, err
}
var resultMap map[string]interface{}
if err := json.Unmarshal([]byte(res), &resultMap); err != nil {
return nil, err
}
if resultMap["Error"] != nil {
return nil, fmt.Errorf("failed to call stub command: %s", resultMap["Error"].(string))
}
return resultMap["Result"], nil
}
func (sad *StubAndroidDriver) AppLaunch(packageName string) (err error) {
_ = sad.EnableDevtool(packageName, true)
err = sad.ADBDriver.AppLaunch(packageName)
if err != nil {
return err
}
return nil
}
func (sad *StubAndroidDriver) Status() (types.DeviceStatus, error) {
app, err := sad.ForegroundInfo()
if err != nil {
return types.DeviceStatus{}, err
}
res, err := sad.sendCommand(app.PackageName, "Hello", nil)
if err != nil {
return types.DeviceStatus{}, err
}
log.Info().Msg(fmt.Sprintf("ping stub result :%v", res))
return types.DeviceStatus{}, nil
}
func (sad *StubAndroidDriver) Source(srcOpt ...option.SourceOption) (source string, err error) {
app, err := sad.ForegroundInfo()
if err != nil {
return "", err
}
params := map[string]interface{}{
"ClassName": "com.bytedance.byteinsight.MockOperator",
"Method": "getLayout",
"RetType": "",
"Args": []string{},
}
res, err := sad.sendCommand(app.PackageName, "CallStaticMethod", params)
if err != nil {
if app.PackageName == "com.ss.android.ugc.aweme" {
log.Error().Err(err).Msg("failed to get source")
}
return "", nil
}
if res.(string) == "{}" {
res, err = sad.sendCommand(app.PackageName, "CallStaticMethod", params)
if err != nil {
if app.PackageName == "com.ss.android.ugc.aweme" {
log.Error().Err(err).Msg("failed to get source")
}
return "", nil
}
}
return res.(string), nil
}
func (sad *StubAndroidDriver) LoginNoneUI(packageName, phoneNumber, captcha, password string) (
info AppLoginInfo, err error) {
app, err := sad.ForegroundInfo()
if err != nil {
return info, err
}
// app.PackageName in ["com.ss.android.ugc.aweme", "com.ss.android.ugc.aweme.lite"]
if app.PackageName == "com.ss.android.ugc.aweme" || app.PackageName == "com.ss.android.ugc.aweme.lite" {
return sad.LoginDouyin(app.PackageName, phoneNumber, captcha, password)
} else if app.PackageName == "com.ss.android.article.video" {
return sad.LoginXigua(app.PackageName, phoneNumber, captcha, password)
} else {
return info, fmt.Errorf("not support app %s", app.PackageName)
}
}
func (sad *StubAndroidDriver) LoginXigua(packageName, phoneNumber, captcha, password string) (
info AppLoginInfo, err error) {
loginSchema := ""
if captcha != "" {
loginSchema = fmt.Sprintf("snssdk32://local_channel_autologin?login_type=1&account=%s&smscode=%s",
phoneNumber, captcha)
} else if password != "" {
loginSchema = fmt.Sprintf("snssdk32://local_channel_autologin?login_type=2&account=%s&password=%s",
phoneNumber, password)
} else {
return info, fmt.Errorf("password and capcha is empty")
}
info.IsLogin = true
return info, sad.OpenUrl(loginSchema)
}
func (sad *StubAndroidDriver) LoginDouyin(packageName, phoneNumber, captcha, password string) (
info AppLoginInfo, err error) {
params := map[string]interface{}{
"phone": phoneNumber,
}
if captcha != "" {
params["captcha"] = captcha
} else if password != "" {
params["password"] = password
} else {
return info, fmt.Errorf("password and capcha is empty")
}
info, err = sad.getLoginAppInfo(packageName)
if err != nil {
log.Err(err).Msg("failed to get login info")
return info, err
}
if info.Did == "" {
_ = sad.Home()
_ = sad.AppLaunch(packageName)
time.Sleep(20 * time.Second)
}
if info.IsLogin {
_ = sad.LogoutNoneUI(packageName)
}
urlPrefix, err := sad.getUrlPrefix(packageName)
if err != nil {
return info, err
}
fullUrl := urlPrefix + "/host/login/account/"
resp, err := sad.Session.POST(params, fullUrl)
if err != nil {
return info, err
}
res, err := resp.ValueConvertToJsonObject()
if err != nil {
return info, err
}
log.Info().Msgf("%v", res)
if res["isSuccess"] != true {
err = fmt.Errorf("falied to login %s", res["data"])
log.Err(err).Msgf("%v", res)
return info, err
}
time.Sleep(20 * time.Second)
info, err = sad.getLoginAppInfo(packageName)
if err != nil || !info.IsLogin {
return info, fmt.Errorf("falied to login %v", info)
}
return info, nil
}
func (sad *StubAndroidDriver) LogoutNoneUI(packageName string) error {
urlPrefix, err := sad.getUrlPrefix(packageName)
if err != nil {
return err
}
fullUrl := urlPrefix + "/host/logout"
resp, err := sad.Session.GET(fullUrl)
if err != nil {
return err
}
res, err := resp.ValueConvertToJsonObject()
if err != nil {
return err
}
log.Info().Msgf("%v", res)
if res["isSuccess"] != true {
err = fmt.Errorf("falied to logout %s", res["data"])
log.Err(err).Msgf("%v", res)
return err
}
return nil
}
func (sad *StubAndroidDriver) EnableDevtool(packageName string, enable bool) error {
urlPrefix, err := sad.getUrlPrefix(packageName)
if err != nil {
return err
}
fullUrl := urlPrefix + "/host/devtool/enable"
params := map[string]interface{}{
"enable": enable,
}
resp, err := sad.Session.POST(params, fullUrl)
if err != nil {
return err
}
res, err := resp.ValueConvertToJsonObject()
if err != nil {
return err
}
log.Info().Msgf("%v", res)
return nil
}
func (sad *StubAndroidDriver) getLoginAppInfo(packageName string) (info AppLoginInfo, err error) {
urlPrefix, err := sad.getUrlPrefix(packageName)
if err != nil {
return info, err
}
fullUrl := urlPrefix + "/host/app/info"
resp, err := sad.Session.GET(fullUrl)
if err != nil {
return info, err
}
res, err := resp.ValueConvertToJsonObject()
if err != nil {
return info, err
}
if res["isSuccess"] != true {
err = fmt.Errorf("falied to get app info %s", res["data"])
log.Err(err).Msgf("%v", res)
return info, err
}
err = json.Unmarshal([]byte(res["data"].(string)), &info)
if err != nil {
err = fmt.Errorf("falied to parse app info %s", res["data"])
return
}
return info, nil
}
func (sad *StubAndroidDriver) getUrlPrefix(packageName string) (urlPrefix string, err error) {
if packageName == "com.ss.android.ugc.aweme" {
urlPrefix = sad.douyinUrlPrefix
} else if packageName == "com.ss.android.ugc.aweme.lite" {
urlPrefix = sad.douyinLiteUrlPrefix
} else {
return "", fmt.Errorf("not support app %s", packageName)
}
return urlPrefix, nil
}

View File

@@ -1,27 +0,0 @@
package driver_ext
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/httprunner/httprunner/v5/pkg/uixt"
)
func setupAndroidStubDriver(t *testing.T) *StubAndroidDriver {
device, err := uixt.NewAndroidDevice()
require.Nil(t, err)
device.Options.UIA2 = false
device.Options.LogOn = false
driver, err := NewStubAndroidDriver(device)
require.Nil(t, err)
return driver
}
func TestAndroidStubDriver_LoginNoneUI(t *testing.T) {
androidStubDriver := setupAndroidStubDriver(t)
info, err := androidStubDriver.LoginNoneUI("com.ss.android.ugc.aweme", "12343418541", "", "im112233")
assert.Nil(t, err)
t.Logf("login info: %+v", info)
}

View File

@@ -1,70 +0,0 @@
package driver_ext
import (
"encoding/json"
"net/http"
"github.com/httprunner/httprunner/v5/pkg/uixt"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/pkg/errors"
)
type StubBrowserDriver struct {
*uixt.BrowserDriver
sessionId string
}
func NewStubBrowserDriver(device *uixt.BrowserDevice) (driver *StubBrowserDriver, err error) {
browserDriver, err := uixt.NewBrowserDriver(device)
if err != nil {
return nil, errors.Wrap(err, "create browser session failed")
}
driver = &StubBrowserDriver{
BrowserDriver: browserDriver,
}
driver.sessionId = device.UUID()
return driver, nil
}
func (wd *StubBrowserDriver) GetDriver() uixt.IDriver {
return wd.BrowserDriver
}
// Source Return application elements tree
func (wd *StubBrowserDriver) Source(srcOpt ...option.SourceOption) (string, error) {
resp, err := wd.BrowserDriver.HttpGet(http.MethodGet, wd.sessionId, "stub/source")
if err != nil {
return "", err
}
jsonData, err := json.Marshal(resp.Data)
if err != nil {
return "", err
}
return string(jsonData), err
}
func (wd *StubBrowserDriver) LoginNoneUI(packageName, phoneNumber, captcha, password string) (
info AppLoginInfo, err error) {
data := map[string]interface{}{
"url": packageName,
"web_cookie": password,
}
resp, err := wd.HttpPOST(data, wd.sessionId, "stub/login")
if err != nil {
return info, err
}
respdata := resp.Data.(map[string]interface{})
loginSuccss := AppLoginInfo{
IsLogin: true,
Uid: respdata["webid"].(string),
Did: password,
}
return loginSuccss, err
}
func (wd *StubBrowserDriver) LogoutNoneUI(packageName string) error {
return errors.New("not implemented")
}

View File

@@ -1,81 +0,0 @@
package driver_ext
import (
"time"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/pkg/uixt"
"github.com/httprunner/httprunner/v5/pkg/uixt/ai"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
)
var (
_ IStubDriver = (*StubAndroidDriver)(nil)
_ IStubDriver = (*StubIOSDriver)(nil)
_ IStubDriver = (*StubBrowserDriver)(nil)
)
type IStubDriver interface {
GetDriver() uixt.IDriver
LoginNoneUI(packageName, phoneNumber, captcha, password string) (info AppLoginInfo, err error)
LogoutNoneUI(packageName string) error
}
func NewStubXTDriver(stubDriver IStubDriver, opts ...ai.AIServiceOption) *StubXTDriver {
services := ai.NewAIService(opts...)
driverExt := &StubXTDriver{
XTDriver: &uixt.XTDriver{
IDriver: stubDriver.GetDriver(),
CVService: services.ICVService,
LLMService: services.ILLMService,
},
IStubDriver: stubDriver,
}
return driverExt
}
type StubXTDriver struct {
*uixt.XTDriver
IStubDriver
}
func (dExt *StubXTDriver) InstallByUrl(url string, opts ...option.InstallOption) error {
appPath, err := uixt.DownloadFileByUrl(url)
if err != nil {
return err
}
err = dExt.Install(appPath, opts...)
if err != nil {
return err
}
return nil
}
func (dExt *StubXTDriver) Install(filePath string, opts ...option.InstallOption) error {
if _, ok := dExt.GetDevice().(*uixt.AndroidDevice); ok {
stopChan := make(chan struct{})
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
go func() {
_ = dExt.TapByOCR("^(.*无视风险安装|正在扫描.*|我知道了|稍后继续|稍后提醒|继续安装|知道了|确定|继续|完成|点击继续安装|继续安装旧版本|替换|.*正在安装|安装|授权本次安装|重新安装|仍要安装|更多详情|我知道了|已了解此应用未经检测.)$", option.WithRegex(true), option.WithIgnoreNotFoundError(true))
//_ = dExt.IDriver.TapByHierarchy("^(.*无视风险安装|正在扫描.*|我知道了|稍后继续|稍后提醒|继续安装|知道了|确定|继续|完成|点击继续安装|继续安装旧版本|替换|.*正在安装|安装|授权本次安装|重新安装|仍要安装|更多详情|我知道了|已了解此应用未经检测.)$", option.WithRegex(true), option.WithIgnoreNotFoundError(true))
}()
case <-stopChan:
log.Info().Msg("install complete")
return
}
}
}()
defer func() {
close(stopChan)
}()
}
return dExt.GetDevice().Install(filePath, opts...)
}

View File

@@ -1,518 +0,0 @@
package driver_ext
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"time"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/pkg/uixt"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
type StubIOSDriver struct {
Device *uixt.IOSDevice
Session *uixt.DriverSession
WDADriver *uixt.WDADriver
timeout time.Duration
douyinUrlPrefix string
douyinLiteUrlPrefix string
}
const (
IOSDouyinPort = 32921
IOSDouyinLitePort = 33461
defaultBightInsightPort = 8000
)
func NewStubIOSDriver(dev *uixt.IOSDevice) (*StubIOSDriver, error) {
driver := &StubIOSDriver{
Device: dev,
timeout: 10 * time.Second,
Session: uixt.NewDriverSession(),
}
// setup driver
if err := driver.Setup(); err != nil {
return nil, err
}
return driver, nil
}
func (s *StubIOSDriver) SetupWda() (err error) {
if s.WDADriver != nil {
return nil
}
s.WDADriver, err = uixt.NewWDADriver(s.Device)
return err
}
func (s *StubIOSDriver) GetDriver() uixt.IDriver {
return s.WDADriver
}
func (s *StubIOSDriver) Setup() error {
localPort, err := s.getLocalPort()
if err != nil {
return err
}
s.Session.SetBaseURL(fmt.Sprintf("http://127.0.0.1:%d", localPort))
localDouyinPort, err := builtin.GetFreePort()
if err != nil {
return errors.Wrap(code.DeviceHTTPDriverError,
fmt.Sprintf("get free port failed: %v", err))
}
if err = s.Device.Forward(localDouyinPort, IOSDouyinPort); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError,
fmt.Sprintf("forward tcp port failed: %v", err))
}
s.douyinUrlPrefix = fmt.Sprintf("http://127.0.0.1:%d", localDouyinPort)
localDouyinLitePort, err := builtin.GetFreePort()
if err != nil {
return errors.Wrap(code.DeviceHTTPDriverError,
fmt.Sprintf("get free port failed: %v", err))
}
if err = s.Device.Forward(localDouyinLitePort, IOSDouyinLitePort); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError,
fmt.Sprintf("forward tcp port failed: %v", err))
}
s.douyinLiteUrlPrefix = fmt.Sprintf("http://127.0.0.1:%d", localDouyinLitePort)
return nil
}
func (s *StubIOSDriver) getLocalPort() (int, error) {
localStubPort, err := builtin.GetFreePort()
if err != nil {
return 0, errors.Wrap(code.DeviceHTTPDriverError,
fmt.Sprintf("get free port failed: %v", err))
}
if err = s.Device.Forward(localStubPort, defaultBightInsightPort); err != nil {
return 0, errors.Wrap(code.DeviceHTTPDriverError,
fmt.Sprintf("forward tcp port failed: %v", err))
}
return localStubPort, nil
}
func (s *StubIOSDriver) Source(srcOpt ...option.SourceOption) (string, error) {
resp, err := s.Session.GET("/source?format=json&onlyWeb=false")
if err != nil {
log.Error().Err(err).Msg("get source err")
return "", nil
}
return string(resp), nil
}
func (s *StubIOSDriver) OpenUrl(urlStr string, opts ...option.ActionOption) (err error) {
targetUrl := fmt.Sprintf("/openURL?url=%s", url.QueryEscape(urlStr))
_, err = s.Session.GET(targetUrl)
if err != nil {
log.Error().Err(err).Msg("get source err")
return nil
}
return nil
}
func (s *StubIOSDriver) LoginNoneUI(packageName, phoneNumber, captcha, password string) (info AppLoginInfo, err error) {
appInfo, err := s.ForegroundInfo()
if err != nil {
return info, err
}
if appInfo.BundleId == "com.ss.iphone.ugc.AwemeInhouse" || appInfo.BundleId == "com.ss.iphone.ugc.awemeinhouse.lite" {
return s.LoginDouyin(appInfo.BundleId, phoneNumber, captcha, password)
} else if appInfo.BundleId == "com.ss.iphone.InHouse.article.Video" {
return s.LoginXigua(appInfo.BundleId, phoneNumber, captcha, password)
} else {
return info, fmt.Errorf("not support app")
}
}
func (s *StubIOSDriver) LoginXigua(packageName, phoneNumber, captcha, password string) (info AppLoginInfo, err error) {
loginSchema := ""
if captcha != "" {
loginSchema = fmt.Sprintf("snssdk32://local_channel_autologin?login_type=1&account=%s&smscode=%s", phoneNumber, captcha)
} else if password != "" {
loginSchema = fmt.Sprintf("snssdk32://local_channel_autologin?login_type=2&account=%s&password=%s", phoneNumber, password)
} else {
return info, fmt.Errorf("password and capcha is empty")
}
info.IsLogin = true
return info, s.OpenUrl(loginSchema)
}
func (s *StubIOSDriver) LoginDouyin(packageName, phoneNumber, captcha, password string) (info AppLoginInfo, err error) {
params := map[string]interface{}{
"phone": phoneNumber,
}
if captcha != "" {
params["captcha"] = captcha
} else if password != "" {
params["password"] = password
} else {
return info, fmt.Errorf("password and capcha is empty")
}
urlPrefix, err := s.getUrlPrefix(packageName)
if err != nil {
return info, err
}
fullUrl := urlPrefix + "/host/login/account/"
resp, err := s.Session.POST(params, fullUrl)
if err != nil {
return info, err
}
res, err := resp.ValueConvertToJsonObject()
if err != nil {
return info, err
}
log.Info().Msgf("%v", res)
// {'isSuccess': True, 'data': '登录成功', 'code': 0}
if res["isSuccess"] != true {
err = fmt.Errorf("falied to logout %s", res["data"])
log.Err(err).Msgf("%v", res)
return info, err
}
time.Sleep(20 * time.Second)
info, err = s.getLoginAppInfo(packageName)
if err != nil || !info.IsLogin {
return info, fmt.Errorf("falied to login %v", info)
}
return info, nil
}
func (s *StubIOSDriver) LogoutNoneUI(packageName string) error {
urlPrefix, err := s.getUrlPrefix(packageName)
if err != nil {
return err
}
fullUrl := urlPrefix + "/host/loginout/"
resp, err := s.Session.GET(fullUrl)
if err != nil {
return err
}
res, err := resp.ValueConvertToJsonObject()
if err != nil {
return err
}
log.Info().Msgf("%v", res)
if res["isSuccess"] != true {
err = fmt.Errorf("falied to logout %s", res["data"])
log.Err(err).Msgf("%v", res)
return err
}
time.Sleep(10 * time.Second)
return nil
}
func (s *StubIOSDriver) EnableDevtool(packageName string, enable bool) (err error) {
urlPrefix, err := s.getUrlPrefix(packageName)
if err != nil {
return err
}
fullUrl := urlPrefix + "/host/devtool/enable"
params := map[string]interface{}{
"enable": enable,
}
resp, err := s.Session.POST(params, fullUrl)
if err != nil {
return err
}
res, err := resp.ValueConvertToJsonObject()
if err != nil {
return err
}
log.Info().Msgf("%v", res)
if res["isSuccess"] != true {
err = fmt.Errorf("falied to enable devtool %s", res["data"])
log.Err(err).Msgf("%v", res)
return err
}
return nil
}
func (s *StubIOSDriver) getLoginAppInfo(packageName string) (info AppLoginInfo, err error) {
urlPrefix, err := s.getUrlPrefix(packageName)
if err != nil {
return info, err
}
fullUrl := urlPrefix + "/host/app/info/"
resp, err := s.Session.GET(fullUrl)
if err != nil {
return info, err
}
res, err := resp.ValueConvertToJsonObject()
if err != nil {
return info, err
}
log.Info().Msgf("%v", res)
if res["isSuccess"] != true {
err = fmt.Errorf("falied to get is login %s", res["data"])
log.Err(err).Msgf("%v", res)
return info, err
}
err = json.Unmarshal([]byte(res["data"].(string)), &info)
if err != nil {
return info, err
}
return info, nil
}
func (s *StubIOSDriver) getUrlPrefix(packageName string) (urlPrefix string, err error) {
if packageName == "com.ss.iphone.ugc.AwemeInhouse" {
urlPrefix = s.douyinUrlPrefix
} else if packageName == "com.ss.iphone.ugc.awemeinhouse.lite" {
urlPrefix = s.douyinLiteUrlPrefix
} else {
return "", fmt.Errorf("not support app %s", packageName)
}
return urlPrefix, nil
}
func (s *StubIOSDriver) ScreenShot(opts ...option.ActionOption) (*bytes.Buffer, error) {
if err := s.SetupWda(); err != nil {
return nil, errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.ScreenShot(opts...)
}
func (s *StubIOSDriver) AppLaunch(packageName string) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
err := s.WDADriver.AppLaunch(packageName)
if err != nil {
return err
}
_ = s.EnableDevtool(packageName, true)
return nil
}
func (s *StubIOSDriver) GetDevice() uixt.IDevice {
return s.Device
}
func (s *StubIOSDriver) TearDown() error {
return nil
}
// session
func (s *StubIOSDriver) InitSession(capabilities option.Capabilities) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.InitSession(capabilities)
}
func (s *StubIOSDriver) GetSession() *uixt.DriverSession {
if err := s.SetupWda(); err != nil {
_ = errors.Wrap(code.DeviceHTTPDriverError, err.Error())
return nil
}
return s.WDADriver.GetSession()
}
func (s *StubIOSDriver) DeleteSession() error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.DeleteSession()
}
// device info and status
func (s *StubIOSDriver) Status() (types.DeviceStatus, error) {
if err := s.SetupWda(); err != nil {
return types.DeviceStatus{}, errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.Status()
}
func (s *StubIOSDriver) DeviceInfo() (types.DeviceInfo, error) {
if err := s.SetupWda(); err != nil {
return types.DeviceInfo{}, errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.DeviceInfo()
}
func (s *StubIOSDriver) BatteryInfo() (types.BatteryInfo, error) {
if err := s.SetupWda(); err != nil {
return types.BatteryInfo{}, errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.BatteryInfo()
}
func (s *StubIOSDriver) ForegroundInfo() (types.AppInfo, error) {
if err := s.SetupWda(); err != nil {
return types.AppInfo{}, errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.ForegroundInfo()
}
func (s *StubIOSDriver) WindowSize() (types.Size, error) {
if err := s.SetupWda(); err != nil {
return types.Size{}, errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.WindowSize()
}
func (s *StubIOSDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) {
if err := s.SetupWda(); err != nil {
return "", errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.ScreenRecord(duration)
}
func (s *StubIOSDriver) Orientation() (types.Orientation, error) {
if err := s.SetupWda(); err != nil {
return "", errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.Orientation()
}
func (s *StubIOSDriver) Rotation() (types.Rotation, error) {
if err := s.SetupWda(); err != nil {
return types.Rotation{}, errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.Rotation()
}
func (s *StubIOSDriver) SetRotation(rotation types.Rotation) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.SetRotation(rotation)
}
func (s *StubIOSDriver) SetIme(ime string) error {
return types.ErrDriverNotImplemented
}
func (s *StubIOSDriver) Home() error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.Home()
}
func (s *StubIOSDriver) Unlock() error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.Unlock()
}
func (s *StubIOSDriver) Back() error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.Back()
}
func (s *StubIOSDriver) TapXY(x, y float64, opts ...option.ActionOption) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.TapXY(x, y, opts...)
}
func (s *StubIOSDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.TapAbsXY(x, y, opts...)
}
func (s *StubIOSDriver) DoubleTap(x, y float64, opts ...option.ActionOption) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.DoubleTap(x, y, opts...)
}
func (s *StubIOSDriver) TouchAndHold(x, y float64, opts ...option.ActionOption) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.TouchAndHold(x, y, opts...)
}
func (s *StubIOSDriver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.Drag(fromX, fromY, toX, toY, opts...)
}
func (s *StubIOSDriver) Swipe(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.Swipe(fromX, fromY, toX, toY, opts...)
}
func (s *StubIOSDriver) Input(text string, opts ...option.ActionOption) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.Input(text, opts...)
}
func (s *StubIOSDriver) Backspace(count int, opts ...option.ActionOption) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.Backspace(count, opts...)
}
func (s *StubIOSDriver) AppTerminate(packageName string) (bool, error) {
if err := s.SetupWda(); err != nil {
return false, errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.AppTerminate(packageName)
}
func (s *StubIOSDriver) AppClear(packageName string) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.AppClear(packageName)
}
// image related
func (s *StubIOSDriver) PushImage(localPath string) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.PushImage(localPath)
}
func (s *StubIOSDriver) ClearImages() error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.ClearImages()
}
// triggers the log capture and returns the log entries
func (s *StubIOSDriver) StartCaptureLog(identifier ...string) error {
if err := s.SetupWda(); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.StartCaptureLog(identifier...)
}
func (s *StubIOSDriver) StopCaptureLog() (interface{}, error) {
if err := s.SetupWda(); err != nil {
return nil, errors.Wrap(code.DeviceHTTPDriverError, err.Error())
}
return s.WDADriver.StopCaptureLog()
}

View File

@@ -1,29 +0,0 @@
package driver_ext
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/httprunner/httprunner/v5/pkg/uixt"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
)
func setupIOSStubDriver(t *testing.T) *StubIOSDriver {
iOSDevice, err := uixt.NewIOSDevice(
option.WithWDAPort(8700),
option.WithWDAMjpegPort(8800),
option.WithResetHomeOnStartup(false))
require.Nil(t, err)
iOSStubDriver, err := NewStubIOSDriver(iOSDevice)
require.Nil(t, err)
return iOSStubDriver
}
func TestIOSStubDriver_LoginNoneUI(t *testing.T) {
iOSStubDriver := setupIOSStubDriver(t)
info, err := iOSStubDriver.LoginNoneUI("com.ss.iphone.ugc.AwemeInhouse", "12343418541", "", "im112233")
assert.Nil(t, err)
t.Logf("login info: %+v", info)
}

View File

@@ -1,150 +0,0 @@
package uixt
import (
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/pkg/uixt/ai"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
)
// TODO: add more popup texts
var popups = [][]string{
{".*青少年.*", "我知道了"}, // 青少年弹窗
{".*个人信息保护.*", "同意"},
{".*通讯录.*", "拒绝"},
{".*更新.*", "以后再说|稍后|取消"},
{".*升级.*", "以后再说|稍后|取消"},
{".*定位.*", "仅.*允许"},
{".*拍照.*", "仅.*允许"},
{".*录音.*", "仅.*允许"},
{".*位置.*", "仅.*允许"},
{".*权限.*", "仅.*允许|始终允许"},
{".*允许.*", "仅.*允许|始终允许"},
{".*风险.*", "继续使用"},
{"管理使用时间", ".*忽略.*"},
}
func findTextPopup(screenTexts ai.OCRTexts) (closePoint *ai.OCRText) {
for _, popup := range popups {
if len(popup) != 2 {
continue
}
points, err := screenTexts.FindTexts([]string{popup[0], popup[1]}, option.WithRegex(true))
if err == nil {
log.Warn().Interface("popup", popup).
Interface("texts", screenTexts).Msg("text popup found")
closePoint = &points[1]
break
}
}
return
}
func (dExt *XTDriver) handleTextPopup(screenTexts ai.OCRTexts) error {
closePoint := findTextPopup(screenTexts)
if closePoint == nil {
// no popup found
return nil
}
log.Info().Str("text", closePoint.Text).Msg("close popup")
pointCenter := closePoint.Center()
if err := dExt.TapAbsXY(pointCenter.X, pointCenter.Y); err != nil {
log.Error().Err(err).Msg("tap popup failed")
return errors.Wrap(code.MobileUIPopupError, err.Error())
}
// tap popup success
return nil
}
func (dExt *XTDriver) AutoPopupHandler() error {
// TODO: check popup by activity type
// check popup by screenshot
texts, err := dExt.GetScreenTexts(
option.WithScreenShotOCR(true),
option.WithScreenShotUpload(true),
option.WithScreenShotFileName("check_popup"),
)
if err != nil {
return errors.Wrap(err, "get screen result failed for popup handler")
}
return dExt.handleTextPopup(texts)
}
type PopupInfo struct {
*ai.ClosePopupsResult
ClosePoints []ai.PointF `json:"close_points,omitempty"` // CV 识别的所有关闭按钮(仅关闭按钮,可能存在多个)
PicName string `json:"pic_name"`
PicURL string `json:"pic_url"`
}
func (p *PopupInfo) ClosePoint() *ai.PointF {
closeResult := p.ClosePopupsResult
if closeResult == nil {
return nil
}
// 弹窗关闭按钮不存在
if closeResult.CloseArea.IsEmpty() {
return nil
}
closePoint := closeResult.CloseArea.Center()
return &closePoint
}
func (dExt *XTDriver) CheckPopup() (popup *PopupInfo, err error) {
screenResult, err := dExt.GetScreenResult(
option.WithScreenShotUpload(true),
option.WithScreenShotClosePopups(true), // get popup area and close area
option.WithScreenShotFileName("check_popup"),
)
if err != nil {
return nil, errors.Wrap(err, "get screen result failed for popup handler")
}
popup = screenResult.Popup
if popup == nil {
// popup not found
log.Debug().Msg("check popup, no found")
return nil, nil
}
closePoint := popup.ClosePoint()
if closePoint == nil {
// close point not found
return nil, errors.Wrap(code.MobileUIPopupError, "popup close point not found")
}
log.Info().Interface("popup", popup).Msg("found popup")
return popup, nil
}
func (dExt *XTDriver) ClosePopupsHandler() (err error) {
log.Info().Msg("try to find and close popups")
popup, err := dExt.CheckPopup()
if err != nil {
// check popup failed
return err
} else if popup == nil {
// no popup found
return nil
}
// found popup
closePoint := popup.ClosePoint()
log.Info().
Interface("closePoint", closePoint).
Interface("popup", popup).
Msg("tap to close popup")
if err := dExt.TapAbsXY(closePoint.X, closePoint.Y); err != nil {
log.Error().Err(err).Msg("tap popup failed")
return errors.Wrap(code.MobileUIPopupError, err.Error())
}
return nil
}

View File

@@ -1,277 +0,0 @@
package uixt
import (
"bytes"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
_ "image/png"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"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/pkg/uixt/ai"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
type ScreenResult struct {
bufSource *bytes.Buffer // raw image buffer bytes
ImagePath string `json:"image_path"` // image file path
Resolution types.Size `json:"resolution"`
UploadedURL string `json:"uploaded_url"` // uploaded image url
Texts ai.OCRTexts `json:"texts"` // dumped raw OCRTexts
Icons ai.UIResultMap `json:"icons"` // CV 识别的图标
Tags []string `json:"tags"` // tags for image, e.g. ["feed", "ad", "live"]
Popup *PopupInfo `json:"popup,omitempty"`
}
func (s *ScreenResult) FilterTextsByScope(x1, y1, x2, y2 float64) ai.OCRTexts {
if x1 > 1 || y1 > 1 || x2 > 1 || y2 > 1 {
log.Warn().Msg("x1, y1, x2, y2 should be in percentage, skip filter scope")
return s.Texts
}
return s.Texts.FilterScope(option.AbsScope{
int(float64(s.Resolution.Width) * x1), int(float64(s.Resolution.Height) * y1),
int(float64(s.Resolution.Width) * x2), int(float64(s.Resolution.Height) * y2),
})
}
// GetScreenResult takes a screenshot, returns the image recognition result
func (dExt *XTDriver) GetScreenResult(opts ...option.ActionOption) (screenResult *ScreenResult, err error) {
screenshotOptions := option.NewActionOptions(opts...)
var fileName string
optionsList := screenshotOptions.List()
if screenshotOptions.ScreenShotFileName != "" {
fileName = builtin.GenNameWithTimestamp("%d_" + screenshotOptions.ScreenShotFileName)
} else if len(optionsList) != 0 {
fileName = builtin.GenNameWithTimestamp("%d_" + strings.Join(optionsList, "_"))
} else {
fileName = builtin.GenNameWithTimestamp("%d_screenshot")
}
var bufSource *bytes.Buffer
var compressBufSource *bytes.Buffer
var imageResult *ai.CVResult
var imagePath string
var windowSize types.Size
var lastErr error
// get screenshot info with retry
for i := 0; i < 3; i++ {
imagePath = filepath.Join(config.GetConfig().ScreenShotsPath, fileName)
bufSource, err = dExt.ScreenShot(option.WithScreenShotFileName(imagePath))
if err != nil {
lastErr = err
continue
}
compressBufSource, err = compressImageBuffer(bufSource)
if err != nil {
lastErr = err
continue
}
windowSize, err = dExt.WindowSize()
if err != nil {
lastErr = errors.Wrap(code.DeviceGetInfoError, err.Error())
continue
}
screenResult = &ScreenResult{
bufSource: compressBufSource,
ImagePath: imagePath,
Tags: nil,
Resolution: windowSize,
}
imageResult, err = dExt.CVService.ReadFromBuffer(compressBufSource, opts...)
if err != nil {
log.Error().Err(err).Msg("ReadFromBuffer from ImageService failed")
lastErr = err
continue
}
// success, break the loop
lastErr = nil
break
}
if lastErr != nil {
return nil, lastErr
}
// cache screen result
dExt.screenResults = append(dExt.screenResults, screenResult)
if imageResult != nil {
screenResult.Texts = imageResult.OCRResult.ToOCRTexts()
screenResult.UploadedURL = imageResult.URL
screenResult.Icons = imageResult.UIResult
if screenshotOptions.ScreenShotWithClosePopups && imageResult.ClosePopupsResult != nil {
screenResult.Popup = &PopupInfo{
ClosePopupsResult: imageResult.ClosePopupsResult,
PicName: imagePath,
PicURL: imageResult.URL,
}
closeAreas, _ := imageResult.UIResult.FilterUIResults([]string{"close"})
for _, closeArea := range closeAreas {
screenResult.Popup.ClosePoints = append(screenResult.Popup.ClosePoints, closeArea.Center())
}
}
}
log.Debug().
Str("imagePath", imagePath).
Str("imageUrl", screenResult.UploadedURL).
Msg("log screenshot")
return screenResult, nil
}
func (dExt *XTDriver) GetScreenTexts(opts ...option.ActionOption) (ocrTexts ai.OCRTexts, err error) {
options := option.NewActionOptions(opts...)
if options.ScreenShotFileName == "" {
opts = append(opts, option.WithScreenShotFileName("get_screen_texts"))
}
opts = append(opts, option.WithScreenShotOCR(true), option.WithScreenShotUpload(true))
screenResult, err := dExt.GetScreenResult(opts...)
if err != nil {
return
}
return screenResult.Texts, nil
}
func (dExt *XTDriver) FindScreenText(text string, opts ...option.ActionOption) (point ai.PointF, err error) {
options := option.NewActionOptions(opts...)
if options.ScreenShotFileName == "" {
opts = append(opts, option.WithScreenShotFileName(fmt.Sprintf("find_screen_text_%s", text)))
}
ocrTexts, err := dExt.GetScreenTexts(opts...)
if err != nil {
return
}
result, err := ocrTexts.FindText(text, opts...)
if err != nil {
log.Warn().Msgf("FindText failed: %s", err.Error())
return
}
point = result.Center()
log.Info().Str("text", text).
Interface("point", point).Msgf("FindScreenText success")
return
}
func (dExt *XTDriver) FindUIResult(opts ...option.ActionOption) (point ai.PointF, err error) {
options := option.NewActionOptions(opts...)
if options.ScreenShotFileName == "" {
opts = append(opts, option.WithScreenShotFileName(
fmt.Sprintf("find_ui_result_%s", strings.Join(options.ScreenShotWithUITypes, "_"))))
}
screenResult, err := dExt.GetScreenResult(opts...)
if err != nil {
return
}
uiResults, err := screenResult.Icons.FilterUIResults(options.ScreenShotWithUITypes)
if err != nil {
return
}
uiResult, err := uiResults.GetUIResult(opts...)
point = uiResult.Center()
log.Info().Interface("text", options.ScreenShotWithUITypes).
Interface("point", point).Msg("FindUIResult success")
return
}
// saveScreenShot saves compressed image file with file name
func saveScreenShot(raw *bytes.Buffer, fileName string) (string, error) {
// notice: screenshot data is a stream, so we need to copy it to a new buffer
copiedBuffer := &bytes.Buffer{}
if _, err := copiedBuffer.Write(raw.Bytes()); err != nil {
log.Error().Err(err).Msg("copy screenshot buffer failed")
}
img, format, err := image.Decode(copiedBuffer)
if err != nil {
return "", errors.Wrap(err, "decode screenshot image failed")
}
// The default format uses jpeg for compression
screenshotPath := filepath.Join(fmt.Sprintf("%s.%s", fileName, "jpeg"))
file, err := os.Create(screenshotPath)
if err != nil {
return "", errors.Wrap(err, "create screenshot image file failed")
}
defer func() {
_ = file.Close()
}()
// compress image and save to file
switch format {
case "jpeg":
jpegOptions := &jpeg.Options{Quality: 95}
err = jpeg.Encode(file, img, jpegOptions)
case "png":
encoder := png.Encoder{
CompressionLevel: png.BestCompression,
}
err = encoder.Encode(file, img)
case "gif":
gifOptions := &gif.Options{
NumColors: 256,
}
err = gif.Encode(file, img, gifOptions)
default:
return "", fmt.Errorf("unsupported image format %s", format)
}
if err != nil {
return "", errors.Wrap(err, "save image file failed")
}
var fileSize int64
fileInfo, err := file.Stat()
if err == nil {
fileSize = fileInfo.Size()
}
log.Info().Str("path", screenshotPath).
Int("rawBytes", raw.Len()).Int64("saveBytes", fileSize).
Msg("save screenshot file success")
return screenshotPath, nil
}
func compressImageBuffer(raw *bytes.Buffer) (compressed *bytes.Buffer, err error) {
// decode image from buffer
img, format, err := image.Decode(raw)
if err != nil {
return nil, err
}
var buf bytes.Buffer
switch format {
// compress image
case "jpeg", "png":
jpegOptions := &jpeg.Options{Quality: 60}
err = jpeg.Encode(&buf, img, jpegOptions)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported image format: %s", format)
}
// return compressed image buffer
return &buf, nil
}

View File

@@ -1,168 +0,0 @@
package uixt
import (
"fmt"
"strings"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/pkg/uixt/ai"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
)
type Action func(driver *XTDriver) error
func (dExt *XTDriver) LoopUntil(findAction, findCondition, foundAction Action, opts ...option.ActionOption) error {
actionOptions := option.NewActionOptions(opts...)
maxRetryTimes := actionOptions.MaxRetryTimes
interval := actionOptions.Interval
for i := 0; i < maxRetryTimes; i++ {
// wait interval between each findAction
time.Sleep(time.Duration(interval) * time.Second)
if err := findCondition(dExt); err == nil {
// do action after found
return foundAction(dExt)
}
if err := findAction(dExt); err != nil {
log.Error().Err(err).Msgf("find action failed")
}
}
return errors.Wrap(code.LoopActionNotFoundError,
fmt.Sprintf("loop %d times, match find condition failed", maxRetryTimes))
}
func prepareSwipeAction(dExt *XTDriver, params interface{}, opts ...option.ActionOption) func(d *XTDriver) error {
actionOptions := option.NewActionOptions(opts...)
var swipeDirection interface{}
// priority: params > actionOptions.Direction, default swipe up
if params != nil {
swipeDirection = params
} else if actionOptions.Direction != nil {
swipeDirection = actionOptions.Direction
} else {
swipeDirection = "up" // default swipe up
}
if actionOptions.Steps == 0 {
actionOptions.Steps = 10
}
return func(d *XTDriver) error {
defer func() {
// wait for swipe action to completed and content to load completely
time.Sleep(time.Duration(1000*actionOptions.Interval) * time.Millisecond)
}()
if d, ok := swipeDirection.(string); ok {
// enum direction: up, down, left, right
switch d {
case "up":
return dExt.Swipe(0.5, 0.5, 0.5, 0.1, opts...)
case "down":
return dExt.Swipe(0.5, 0.5, 0.5, 0.9, opts...)
case "left":
return dExt.Swipe(0.5, 0.5, 0.1, 0.5, opts...)
case "right":
return dExt.Swipe(0.5, 0.5, 0.9, 0.5, opts...)
default:
return errors.Wrap(code.InvalidParamError,
fmt.Sprintf("get unexpected swipe direction: %s", d))
}
} else if params, err := builtin.ConvertToFloat64Slice(swipeDirection); err == nil && len(params) == 4 {
// custom direction: [fromX, fromY, toX, toY]
if err := dExt.Swipe(params[0], params[1], params[2], params[3], opts...); err != nil {
log.Error().Err(err).Msgf("swipe from (%v, %v) to (%v, %v) failed",
params[0], params[1], params[2], params[3])
return err
}
} else {
return fmt.Errorf("invalid swipe params %v", swipeDirection)
}
return nil
}
}
func (dExt *XTDriver) SwipeToTapTexts(texts []string, opts ...option.ActionOption) error {
if len(texts) == 0 {
return errors.New("no text to tap")
}
opts = append(opts, option.WithMatchOne(true), option.WithRegex(true))
actionOptions := option.NewActionOptions(opts...)
actionOptions.Identifier = ""
optionsWithoutIdentifier := actionOptions.Options()
var point ai.PointF
findTexts := func(d *XTDriver) error {
var err error
screenResult, err := d.GetScreenResult(
option.WithScreenShotOCR(true),
option.WithScreenShotUpload(true),
option.WithScreenShotFileName(
fmt.Sprintf("swipe_to_tap_texts_%s", strings.Join(texts, "_")),
),
)
if err != nil {
return err
}
points, err := screenResult.Texts.FindTexts(texts,
convertToAbsoluteScope(dExt.IDriver, optionsWithoutIdentifier...)...)
if err != nil {
log.Error().Err(err).Strs("texts", texts).Msg("find texts failed")
return err
}
log.Info().Strs("texts", texts).Interface("results", points).Msg("swipeToTapTexts successful")
// target texts found, pick the first one
point = points[0].Center() // FIXME
return nil
}
foundTextAction := func(d *XTDriver) error {
// tap text
return d.TapAbsXY(point.X, point.Y, opts...)
}
findAction := prepareSwipeAction(dExt, nil, optionsWithoutIdentifier...)
return dExt.LoopUntil(findAction, findTexts, foundTextAction, optionsWithoutIdentifier...)
}
func (dExt *XTDriver) SwipeToTapApp(appName string, opts ...option.ActionOption) error {
// go to home screen
if err := dExt.Home(); err != nil {
return errors.Wrap(err, "go to home screen failed")
}
// automatic handling popups before swipe
if err := dExt.ClosePopupsHandler(); err != nil {
log.Error().Err(err).Msg("auto handle popup failed")
}
// swipe to first screen
for i := 0; i < 5; i++ {
dExt.Swipe(0.5, 0.5, 0.9, 0.5, opts...)
}
opts = append(opts, option.WithDirection("left"))
opts = append(opts, option.WithMaxRetryTimes(5))
actionOptions := option.NewActionOptions(opts...)
// tap app icon above the text
if len(actionOptions.Offset) == 0 {
opts = append(opts, option.WithTapOffset(0, -25))
}
// set default swipe interval to 1 second
if builtin.IsZeroFloat64(actionOptions.Interval) {
opts = append(opts, option.WithInterval(1))
}
return dExt.SwipeToTapTexts([]string{appName}, opts...)
}

View File

@@ -1,38 +0,0 @@
package uixt
import (
"fmt"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
)
func (dExt *XTDriver) TapByOCR(text string, opts ...option.ActionOption) error {
actionOptions := option.NewActionOptions(opts...)
if actionOptions.ScreenShotFileName == "" {
opts = append(opts, option.WithScreenShotFileName(fmt.Sprintf("tap_by_ocr_%s", text)))
}
point, err := dExt.FindScreenText(text, opts...)
if err != nil {
if actionOptions.IgnoreNotFoundError {
return nil
}
return err
}
return dExt.TapAbsXY(point.X, point.Y, opts...)
}
func (dExt *XTDriver) TapByCV(opts ...option.ActionOption) error {
options := option.NewActionOptions(opts...)
point, err := dExt.FindUIResult(opts...)
if err != nil {
if options.IgnoreNotFoundError {
return nil
}
return err
}
return dExt.TapAbsXY(point.X, point.Y, opts...)
}

View File

@@ -1,165 +0,0 @@
//go:build localtest
package uixt
import (
"testing"
"time"
"github.com/httprunner/httprunner/v5/pkg/uixt/ai"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDriverExt_NewMethod1(t *testing.T) {
device, err := NewAndroidDevice(option.WithUIA2(true))
require.Nil(t, err)
driver, err := device.NewDriver()
require.Nil(t, err)
driverExt := NewXTDriver(driver,
ai.WithCVService(ai.CVServiceTypeVEDEM))
driverExt.TapByOCR("推荐")
}
func TestDriverExt_NewMethod2(t *testing.T) {
device, err := NewAndroidDevice()
require.Nil(t, err)
driver, err := NewUIA2Driver(device)
require.Nil(t, err)
driverExt := NewXTDriver(driver,
ai.WithCVService(ai.CVServiceTypeVEDEM))
driverExt.TapByOCR("推荐")
}
func TestDriverExt(t *testing.T) {
device, _ := NewAndroidDevice()
driver, _ := NewADBDriver(device)
driverExt := NewXTDriver(driver,
ai.WithCVService(ai.CVServiceTypeVEDEM))
// call IDriver methods
driverExt.TapXY(0.2, 0.5)
driverExt.Swipe(0.2, 0.5, 0.8, 0.5)
driverExt.AppLaunch("com.ss.android.ugc.aweme")
// call AI extended methods
driverExt.TapByOCR("推荐")
texts, _ := driverExt.GetScreenTexts()
t.Log(texts)
point, _ := driverExt.FindScreenText("hello")
t.Log(point)
// call IDriver methods
driverExt.GetDevice().Install("/path/to/app")
driverExt.GetDevice().GetPackageInfo("com.ss.android.ugc.aweme")
// get original driver and call its methods
adbDriver := driverExt.IDriver.(*ADBDriver)
adbDriver.TapByHierarchy("hello")
wdaDriver := driverExt.IDriver.(*WDADriver)
wdaDriver.GetMjpegClient()
wdaDriver.Scale()
// get original device and call its methods
androidDevice := driver.GetDevice().(*AndroidDevice)
androidDevice.InstallAPK("/path/to/app.apk")
}
var driverType = "ADB"
func setupDriverExt(t *testing.T) *XTDriver {
switch driverType {
case "ADB":
return setupADBDriverExt(t)
case "UIA2":
return setupUIA2DriverExt(t)
case "WDA":
return setupWDADriverExt(t)
case "HDC":
return setupHDCDriverExt(t)
default:
return setupADBDriverExt(t)
}
}
func TestDriverExt_FindScreenText(t *testing.T) {
driver := setupDriverExt(t)
point, err := driver.FindScreenText("首页")
assert.Nil(t, err)
t.Log(point)
}
func TestDriverExt_Seek(t *testing.T) {
driver := setupDriverExt(t)
point, err := driver.FindScreenText("首页")
assert.Nil(t, err)
size, err := driver.WindowSize()
assert.Nil(t, err)
width := size.Width
y := point.Y - 40
for i := 0; i < 5; i++ {
err := driver.Swipe(0.5, 0.8, 0.5, 0.2)
assert.Nil(t, err)
time.Sleep(1 * time.Second)
err = driver.Drag(20, y, float64(width)*0.75, y)
assert.Nil(t, err)
time.Sleep(1 * time.Second)
}
}
func TestDriverExt_TapByOCR(t *testing.T) {
driver := setupDriverExt(t)
err := driver.TapByOCR("天气")
assert.Nil(t, err)
}
func TestDriverExt_prepareSwipeAction(t *testing.T) {
driver := setupDriverExt(t)
swipeAction := prepareSwipeAction(driver, "up", option.WithDirection("down"))
err := swipeAction(driver)
assert.Nil(t, err)
swipeAction = prepareSwipeAction(driver, "up", option.WithCustomDirection(0.5, 0.5, 0.5, 0.9))
err = swipeAction(driver)
assert.Nil(t, err)
}
func TestDriverExt_SwipeToTapApp(t *testing.T) {
driver := setupDriverExt(t)
err := driver.SwipeToTapApp("抖音")
assert.Nil(t, err)
}
func TestDriverExt_SwipeToTapTexts(t *testing.T) {
driver := setupDriverExt(t)
err := driver.AppLaunch("com.ss.android.ugc.aweme")
assert.Nil(t, err)
err = driver.SwipeToTapTexts(
[]string{"点击进入直播间", "直播中"},
option.WithDirection("up"),
option.WithMaxRetryTimes(10))
assert.Nil(t, err)
}
func TestDriverExt_CheckPopup(t *testing.T) {
driver := setupADBDriverExt(t)
popup, err := driver.CheckPopup()
require.Nil(t, err)
if popup == nil {
t.Log("no popup found")
} else {
t.Logf("found popup: %v", popup)
}
}
func TestDriverExt_ClosePopupsHandler(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.ClosePopupsHandler()
assert.Nil(t, err)
}

View File

@@ -1,353 +0,0 @@
package uixt
import (
"bytes"
"context"
"encoding/base64"
builtinJSON "encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"regexp"
"strings"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/internal/json"
)
type Attachments map[string]interface{}
type DriverRequests struct {
RequestMethod string `json:"request_method"`
RequestUrl string `json:"request_url"`
RequestBody string `json:"request_body,omitempty"`
RequestTime time.Time `json:"request_time"`
ResponseStatus int `json:"response_status"`
ResponseDuration int64 `json:"response_duration(ms)"` // ms
ResponseBody string `json:"response_body"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
const (
emptySessionID = "<SessionNotInit>"
)
func NewDriverSession() *DriverSession {
timeout := 30 * time.Second
session := &DriverSession{
ctx: context.Background(),
ID: emptySessionID,
timeout: timeout,
client: &http.Client{
Timeout: timeout,
},
requests: make([]*DriverRequests, 0),
maxRetry: 5,
}
return session
}
type DriverSession struct {
ctx context.Context
ID string
baseUrl string
client *http.Client
timeout time.Duration
maxRetry int
// used to reset driver session when request failed
resetFn func() error
// cache driver request and response
requests []*DriverRequests
}
func (s *DriverSession) Reset() {
s.requests = make([]*DriverRequests, 0)
}
func (s *DriverSession) SetBaseURL(baseUrl string) {
s.baseUrl = baseUrl
}
func (s *DriverSession) RegisterResetHandler(fn func() error) {
s.resetFn = fn
}
func (s *DriverSession) addRequestResult(driverResult *DriverRequests) {
s.requests = append(s.requests, driverResult)
}
func (s *DriverSession) History() []*DriverRequests {
return s.requests
}
func (s *DriverSession) concatURL(urlStr string) (string, error) {
if urlStr == "" || urlStr == "/" {
if s.baseUrl == "" {
return "", fmt.Errorf("base URL is empty")
}
return s.baseUrl, nil
}
// replace with session ID
if s.ID != emptySessionID {
urlStr = strings.Replace(urlStr, emptySessionID, s.ID, 1)
}
// 处理完整 URL
if strings.HasPrefix(urlStr, "http://") || strings.HasPrefix(urlStr, "https://") {
u, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("failed to parse URL: %w", err)
}
return u.String(), nil
}
// 处理相对路径
if s.baseUrl == "" {
return "", fmt.Errorf("base URL is empty")
}
u, err := url.Parse(s.baseUrl)
if err != nil {
return "", fmt.Errorf("failed to parse base URL: %w", err)
}
// 处理路径和查询参数
parts := strings.SplitN(urlStr, "?", 2)
u.Path = path.Join(u.Path, parts[0])
if len(parts) > 1 {
query, err := url.ParseQuery(parts[1])
if err != nil {
return "", fmt.Errorf("failed to parse query params: %w", err)
}
u.RawQuery = query.Encode()
}
return u.String(), nil
}
func (s *DriverSession) GET(urlStr string) (rawResp DriverRawResponse, err error) {
return s.RequestWithRetry(http.MethodGet, urlStr, nil)
}
func (s *DriverSession) POST(data interface{}, urlStr string) (rawResp DriverRawResponse, err error) {
var bsJSON []byte = nil
if data != nil {
if bsJSON, err = json.Marshal(data); err != nil {
return nil, err
}
}
return s.RequestWithRetry(http.MethodPost, urlStr, bsJSON)
}
func (s *DriverSession) DELETE(urlStr string) (rawResp DriverRawResponse, err error) {
return s.RequestWithRetry(http.MethodDelete, urlStr, nil)
}
func (s *DriverSession) RequestWithRetry(method string, urlStr string, rawBody []byte) (
rawResp DriverRawResponse, err error) {
for count := 1; count <= s.maxRetry; count++ {
rawResp, err = s.Request(method, urlStr, rawBody)
if err == nil {
return
}
time.Sleep(3 * time.Second)
if s.resetFn != nil {
log.Warn().Msg("reset driver session")
if err2 := s.resetFn(); err2 != nil {
log.Error().Err(err2).Msgf(
"failed to reset session, try count %v", count)
} else {
log.Info().Msgf(
"reset session success, try count %v", count)
}
}
}
return
}
func (s *DriverSession) Request(method string, urlStr string, rawBody []byte) (
rawResp DriverRawResponse, err error) {
// concat url with base url
rawURL, err := s.concatURL(urlStr)
if err != nil {
return nil, err
}
driverResult := &DriverRequests{
RequestMethod: method,
RequestUrl: rawURL,
RequestBody: string(rawBody),
}
defer func() {
s.addRequestResult(driverResult)
var logger *zerolog.Event
if err != nil {
driverResult.Success = false
driverResult.Error = err.Error()
logger = log.Error().Bool("success", false).Err(err)
} else {
driverResult.Success = true
logger = log.Debug().Bool("success", true)
}
logger = logger.Str("request_method", method).Str("request_url", rawURL).
Str("request_body", string(rawBody))
if !driverResult.RequestTime.IsZero() {
logger = logger.Int64("request_time", driverResult.RequestTime.UnixMilli())
}
if driverResult.ResponseStatus != 0 {
logger = logger.
Int("response_status", driverResult.ResponseStatus).
Int64("response_duration(ms)", driverResult.ResponseDuration).
Str("response_body", driverResult.ResponseBody)
}
logger.Msg("request uixt driver")
}()
ctx, cancel := context.WithTimeout(s.ctx, s.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewBuffer(rawBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
req.Header.Set("Accept", "application/json")
driverResult.RequestTime = time.Now()
var resp *http.Response
if resp, err = s.client.Do(req); err != nil {
return nil, err
}
defer func() {
// https://github.com/etcd-io/etcd/blob/v3.3.25/pkg/httputil/httputil.go#L16-L22
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
rawResp, err = io.ReadAll(resp.Body)
duration := time.Since(driverResult.RequestTime)
driverResult.ResponseDuration = duration.Milliseconds()
driverResult.ResponseStatus = resp.StatusCode
if strings.HasSuffix(rawURL, "screenshot") {
// avoid printing screenshot data
driverResult.ResponseBody = "OMITTED"
} else {
driverResult.ResponseBody = string(rawResp)
}
if err != nil {
return nil, err
}
if err = rawResp.CheckErr(); err != nil {
if resp.StatusCode == http.StatusOK {
return rawResp, nil
}
return nil, err
}
return
}
func (s *DriverSession) SetupPortForward(localPort int) error {
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
if err != nil {
return fmt.Errorf("create tcp connection error %v", err)
}
s.client.Transport = &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return conn, nil
},
}
return nil
}
type DriverRawResponse []byte
func (r DriverRawResponse) CheckErr() (err error) {
reply := new(struct {
Value struct {
Err string `json:"error"`
Message string `json:"message"`
Traceback string `json:"traceback"` // wda
Stacktrace string `json:"stacktrace"` // uia
}
})
if err = json.Unmarshal(r, reply); err != nil {
return err
}
if reply.Value.Err != "" {
errText := reply.Value.Message
re := regexp.MustCompile(`{.+?=(.+?)}`)
if re.MatchString(reply.Value.Message) {
subMatch := re.FindStringSubmatch(reply.Value.Message)
errText = subMatch[len(subMatch)-1]
}
return fmt.Errorf("%s: %s", reply.Value.Err, errText)
}
return
}
func (r DriverRawResponse) ValueConvertToString() (s string, err error) {
reply := new(struct{ Value string })
if err = json.Unmarshal(r, reply); err != nil {
return "", errors.Wrapf(err, "json.Unmarshal failed, rawResponse: %s", string(r))
}
s = reply.Value
return
}
func (r DriverRawResponse) ValueConvertToBool() (b bool, err error) {
reply := new(struct{ Value bool })
if err = json.Unmarshal(r, reply); err != nil {
return false, err
}
b = reply.Value
return
}
func (r DriverRawResponse) ValueConvertToJsonRawMessage() (raw builtinJSON.RawMessage, err error) {
reply := new(struct{ Value builtinJSON.RawMessage })
if err = json.Unmarshal(r, reply); err != nil {
return nil, err
}
raw = reply.Value
return
}
func (r DriverRawResponse) ValueConvertToJsonObject() (obj map[string]interface{}, err error) {
if err = json.Unmarshal(r, &obj); err != nil {
return nil, err
}
return
}
func (r DriverRawResponse) ValueDecodeAsBase64() (raw *bytes.Buffer, err error) {
str, err := r.ValueConvertToString()
if err != nil {
return nil, errors.Wrap(err, "failed to convert value to string")
}
decodeString, err := base64.StdEncoding.DecodeString(str)
if err != nil {
return nil, errors.Wrap(err, "failed to decode base64 string")
}
raw = bytes.NewBuffer(decodeString)
return
}

View File

@@ -1,142 +0,0 @@
package uixt
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDriverSession_concatURL(t *testing.T) {
tests := []struct {
name string
baseUrl string
urlStr string
want string
wantErr bool
errMsg string
}{
{
name: "empty url with empty base url",
baseUrl: "",
urlStr: "",
wantErr: true,
errMsg: "base URL is empty",
},
{
name: "empty url with valid base url",
baseUrl: "http://localhost:8080",
urlStr: "",
want: "http://localhost:8080",
},
{
name: "root path with empty base url",
baseUrl: "",
urlStr: "/",
wantErr: true,
errMsg: "base URL is empty",
},
{
name: "root path with valid base url",
baseUrl: "http://localhost:8080",
urlStr: "/",
want: "http://localhost:8080",
},
{
name: "absolute http url",
baseUrl: "http://localhost:8080",
urlStr: "http://example.com/api",
want: "http://example.com/api",
},
{
name: "absolute https url",
baseUrl: "http://localhost:8080",
urlStr: "https://example.com/api",
want: "https://example.com/api",
},
{
name: "invalid absolute url",
baseUrl: "http://localhost:8080",
urlStr: "http://[invalid-url",
wantErr: true,
errMsg: "failed to parse URL",
},
{
name: "relative path with empty base url",
baseUrl: "",
urlStr: "api/users",
wantErr: true,
errMsg: "base URL is empty",
},
{
name: "relative path with invalid base url",
baseUrl: "http://[invalid-url",
urlStr: "api/users",
wantErr: true,
errMsg: "failed to parse base URL",
},
{
name: "relative path with valid base url",
baseUrl: "http://localhost:8080",
urlStr: "api/users",
want: "http://localhost:8080/api/users",
},
{
name: "relative path with query params",
baseUrl: "http://localhost:8080",
urlStr: "api/users?id=1&name=test",
want: "http://localhost:8080/api/users?id=1&name=test",
},
{
name: "base url with query params",
baseUrl: "http://localhost:8080?token=123",
urlStr: "api/users?id=1",
want: "http://localhost:8080/api/users?id=1",
},
{
name: "invalid query params",
baseUrl: "http://localhost:8080",
urlStr: "api/users?id=%invalid",
wantErr: true,
errMsg: "failed to parse query params",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &DriverSession{baseUrl: tt.baseUrl}
got, err := s.concatURL(tt.urlStr)
if tt.wantErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestDriverSession(t *testing.T) {
session := NewDriverSession()
session.SetBaseURL("https://postman-echo.com")
resp, err := session.GET("/get")
assert.Nil(t, err)
t.Log(resp)
resp, err = session.GET("/get?a=1&b=2")
assert.Nil(t, err)
t.Log(resp)
driverRequests := session.History()
assert.Equal(t, 2, len(driverRequests))
session.Reset()
driverRequests = session.History()
assert.Equal(t, 0, len(driverRequests))
resp, err = session.GET("https://postman-echo.com/get")
assert.Nil(t, err)
t.Log(resp)
}

View File

@@ -1,343 +0,0 @@
package uixt
import (
"crypto/md5"
"fmt"
"io"
"math"
"math/rand/v2"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"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/pkg/uixt/option"
)
func convertToAbsoluteScope(driver IDriver, opts ...option.ActionOption) []option.ActionOption {
actionOptions := option.NewActionOptions(opts...)
// convert relative scope to absolute scope
if len(actionOptions.AbsScope) != 4 && len(actionOptions.Scope) == 4 {
scope := actionOptions.Scope
x1, y1, x2, y2, err := convertToAbsoluteCoordinates(
driver, scope[0], scope[1], scope[2], scope[3])
if err != nil {
log.Error().Err(err).Msg("convert absolute scope failed")
return opts
}
actionOptions.AbsScope = []int{int(x1), int(y1), int(x2), int(y2)}
}
return actionOptions.Options()
}
func convertToAbsolutePoint(driver IDriver, x, y float64) (absX, absY float64, err error) {
// absolute coordinates
if x > 1 || y > 1 {
return x, y, nil
}
// relative coordinates
if assertRelative(x) && assertRelative(y) {
windowSize, err := driver.WindowSize()
if err != nil {
err = errors.Wrap(code.DeviceGetInfoError, err.Error())
return 0, 0, err
}
absX = math.Round(float64(windowSize.Width)*x*10) / 10
absY = math.Round(float64(windowSize.Height)*y*10) / 10
return absX, absY, nil
}
// invalid coordinates
err = errors.Wrap(code.InvalidCaseError,
fmt.Sprintf("invalid coordinates x(%f), y(%f)", x, y))
return
}
func convertToAbsoluteCoordinates(driver IDriver, fromX, fromY, toX, toY float64) (
absFromX, absFromY, absToX, absToY float64, err error) {
// absolute coordinates
if fromX > 1 || toX > 1 || fromY > 1 || toY > 1 {
return fromX, fromY, toX, toY, nil
}
// relative coordinates
if assertRelative(fromX) && assertRelative(fromY) &&
assertRelative(toX) && assertRelative(toY) {
windowSize, err := driver.WindowSize()
if err != nil {
err = errors.Wrap(code.DeviceGetInfoError, err.Error())
return 0, 0, 0, 0, err
}
width := windowSize.Width
height := windowSize.Height
absFromX = float64(width) * fromX
absFromY = float64(height) * fromY
absToX = float64(width) * toX
absToY = float64(height) * toY
return absFromX, absFromY, absToX, absToY, nil
}
// invalid coordinates
err = errors.Wrap(code.InvalidCaseError,
fmt.Sprintf("invalid coordinates fromX(%f), fromY(%f), toX(%f), toY(%f)",
fromX, fromY, toX, toY))
return
}
func assertRelative(p float64) bool {
return p >= 0 && p <= 1
}
func (dExt *XTDriver) Setup() error {
// unlock device screen
err := dExt.Unlock()
if err != nil {
log.Error().Err(err).Msg("unlock device screen failed")
return err
}
return nil
}
func (dExt *XTDriver) GetData(withReset bool) map[string]interface{} {
session := dExt.GetSession()
data := map[string]interface{}{
"requests": session.History(),
"screen_results": dExt.screenResults,
}
if withReset {
session.Reset()
dExt.screenResults = make([]*ScreenResult, 0)
}
return data
}
func (dExt *XTDriver) assertOCR(text, assert string) error {
var opts []option.ActionOption
opts = append(opts, option.WithScreenShotFileName(fmt.Sprintf("assert_ocr_%s", text)))
switch assert {
case AssertionEqual:
_, err := dExt.FindScreenText(text, opts...)
if err != nil {
return errors.Wrap(err, "assert ocr equal failed")
}
case AssertionNotEqual:
_, err := dExt.FindScreenText(text, opts...)
if err == nil {
return errors.New("assert ocr not equal failed")
}
case AssertionExists:
opts = append(opts, option.WithRegex(true))
_, err := dExt.FindScreenText(text, opts...)
if err != nil {
return errors.Wrap(err, "assert ocr exists failed")
}
case AssertionNotExists:
opts = append(opts, option.WithRegex(true))
_, err := dExt.FindScreenText(text, opts...)
if err == nil {
return errors.New("assert ocr not exists failed")
}
default:
return fmt.Errorf("unexpected assert method %s", assert)
}
return nil
}
func (dExt *XTDriver) assertForegroundApp(appName, assert string) error {
app, err := dExt.ForegroundInfo()
if err != nil {
log.Warn().Err(err).Msg("get foreground app failed, skip app assertion")
return nil // Notice: ignore error when get foreground app failed
}
switch assert {
case AssertionEqual:
if app.PackageName != appName {
return errors.Wrap(err, "assert foreground app equal failed")
}
case AssertionNotEqual:
if app.PackageName == appName {
return errors.New("assert foreground app not equal failed")
}
default:
return fmt.Errorf("unexpected assert method %s", assert)
}
return nil
}
func (dExt *XTDriver) DoValidation(check, assert, expected string, message ...string) (err error) {
switch check {
case SelectorOCR:
err = dExt.assertOCR(expected, assert)
case SelectorForegroundApp:
err = dExt.assertForegroundApp(expected, assert)
}
if err != nil {
if message == nil {
message = []string{""}
}
log.Error().Err(err).Str("assert", assert).Str("expect", expected).
Str("msg", message[0]).Msg("validate failed")
return err
}
log.Info().Str("assert", assert).Str("expect", expected).Msg("validate success")
return nil
}
type SleepConfig struct {
StartTime time.Time `json:"start_time"`
Seconds float64 `json:"seconds,omitempty"`
Milliseconds int64 `json:"milliseconds,omitempty"`
}
// getSimulationDuration returns simulation duration by given params (in seconds)
func getSimulationDuration(params []float64) (milliseconds int64) {
if len(params) == 1 {
// given constant duration time
return int64(params[0] * 1000)
}
if len(params) == 2 {
// given [min, max], missing weight
// append default weight 1
params = append(params, 1.0)
}
var sections []struct {
min, max, weight float64
}
totalProb := 0.0
for i := 0; i+3 <= len(params); i += 3 {
min := params[i]
max := params[i+1]
weight := params[i+2]
totalProb += weight
sections = append(sections,
struct{ min, max, weight float64 }{min, max, weight},
)
}
if totalProb == 0 {
log.Warn().Msg("total weight is 0, skip simulation")
return 0
}
r := rand.Float64()
accProb := 0.0
for _, s := range sections {
accProb += s.weight / totalProb
if r < accProb {
milliseconds := int64((s.min + rand.Float64()*(s.max-s.min)) * 1000)
log.Info().Int64("random(ms)", milliseconds).
Interface("strategy_params", params).Msg("get simulation duration")
return milliseconds
}
}
log.Warn().Interface("strategy_params", params).
Msg("get simulation duration failed, skip simulation")
return 0
}
// sleepStrict sleeps strict duration with given params
// startTime is used to correct sleep duration caused by process time
func sleepStrict(startTime time.Time, strictMilliseconds int64) {
var elapsed int64
if !startTime.IsZero() {
elapsed = time.Since(startTime).Milliseconds()
}
dur := strictMilliseconds - elapsed
// if elapsed time is greater than given duration, skip sleep to reduce deviation caused by process time
if dur <= 0 {
log.Warn().
Int64("elapsed(ms)", elapsed).
Int64("strictSleep(ms)", strictMilliseconds).
Msg("elapsed >= simulation duration, skip sleep")
return
}
log.Info().Int64("sleepDuration(ms)", dur).
Int64("elapsed(ms)", elapsed).
Int64("strictSleep(ms)", strictMilliseconds).
Msg("sleep remaining duration time")
time.Sleep(time.Duration(dur) * time.Millisecond)
}
// global file lock
var (
fileLocks sync.Map
)
func DownloadFileByUrl(fileUrl string) (filePath string, err error) {
hash := md5.Sum([]byte(fileUrl))
fileName := fmt.Sprintf("%x", hash)
filePath = filepath.Join(config.GetConfig().DownloadsPath, fileName)
// get or create file lock
lockI, _ := fileLocks.LoadOrStore(filePath, &sync.Mutex{})
lock := lockI.(*sync.Mutex)
lock.Lock()
defer lock.Unlock()
if builtin.FileExists(filePath) {
return filePath, nil
}
log.Info().Str("fileUrl", fileUrl).Str("filePath", filePath).Msg("downloading file")
// Create an HTTP client with default settings.
client := &http.Client{}
// Build the HTTP GET request.
req, err := http.NewRequest("GET", fileUrl, nil)
if err != nil {
return "", err
}
// Perform the request.
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Check the HTTP status code.
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download file: %s", resp.Status)
}
// Create the output file.
outFile, err := os.Create(filePath)
if err != nil {
return "", err
}
defer outFile.Close()
// Copy the response body to the file.
_, err = io.Copy(outFile, resp.Body)
if err != nil {
return "", err
}
log.Info().Str("filePath", filePath).Msg("download file success")
return filePath, nil
}

View File

@@ -1,53 +0,0 @@
package uixt
import (
"strings"
"testing"
"time"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/stretchr/testify/assert"
)
func TestGetSimulationDuration(t *testing.T) {
params := []float64{1.23}
duration := getSimulationDuration(params)
if duration != 1230 {
t.Fatal("getSimulationDuration failed")
}
params = []float64{1, 2}
duration = getSimulationDuration(params)
if duration < 1000 || duration > 2000 {
t.Fatal("getSimulationDuration failed")
}
params = []float64{1, 5, 0.7, 5, 10, 0.3}
duration = getSimulationDuration(params)
if duration < 1000 || duration > 10000 {
t.Fatal("getSimulationDuration failed")
}
}
func TestSleepStrict(t *testing.T) {
startTime := time.Now()
sleepStrict(startTime, 1230)
dur := time.Since(startTime).Milliseconds()
t.Log(dur)
if dur < 1230 || dur > 1300 {
t.Fatalf("sleepRandom failed, dur: %d", dur)
}
}
func TestUtils_GetFreePort(t *testing.T) {
freePort, err := builtin.GetFreePort()
assert.Nil(t, err)
assert.Greater(t, freePort, 10000)
t.Log(freePort)
}
func TestUtils_ConvertPoints(t *testing.T) {
data := "10-09 20:16:48.216 I/iesqaMonitor(17845): {\"duration\":0,\"end\":1665317808206,\"ext\":\"输入\",\"from\":{\"x\":0.0,\"y\":0.0},\"operation\":\"Gtf-SendKeys\",\"run_time\":627,\"start\":1665317807579,\"start_first\":0,\"start_last\":0,\"to\":{\"x\":0.0,\"y\":0.0}}\n10-09 20:18:22.899 I/iesqaMonitor(17845): {\"duration\":0,\"end\":1665317902898,\"ext\":\"进入直播间\",\"from\":{\"x\":717.0,\"y\":2117.5},\"operation\":\"Gtf-Tap\",\"run_time\":121,\"start\":1665317902777,\"start_first\":0,\"start_last\":0,\"to\":{\"x\":717.0,\"y\":2117.5}}\n10-09 20:18:32.063 I/iesqaMonitor(17845): {\"duration\":0,\"end\":1665317912062,\"ext\":\"第一次上划\",\"from\":{\"x\":1437.0,\"y\":2409.9},\"operation\":\"Gtf-Swipe\",\"run_time\":32,\"start\":1665317912030,\"start_first\":0,\"start_last\":0,\"to\":{\"x\":1437.0,\"y\":2409.9}}"
eps := ConvertPoints(strings.Split(data, "\n"))
assert.Equal(t, 3, len(eps))
}

Binary file not shown.

View File

@@ -1,113 +0,0 @@
package uixt
import (
"bytes"
"code.byted.org/iesqa/ghdc"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
type HarmonyDevice struct {
*ghdc.Device
Options *option.HarmonyDeviceOptions
}
func NewHarmonyDevice(opts ...option.HarmonyDeviceOption) (device *HarmonyDevice, err error) {
deviceConfig := option.NewHarmonyDeviceOptions(opts...)
// get all attached android devices
hdcClient, err := ghdc.NewClientWith(option.HdcServerHost, option.HdcServerPort)
if err != nil {
return nil, err
}
devices, err := hdcClient.DeviceList()
if err != nil {
return nil, err
}
if len(devices) == 0 {
return nil, errors.Wrapf(code.DeviceConnectionError,
"no attached harmony devices")
}
// filter device by serial
var harmonyDevice *ghdc.Device
if deviceConfig.ConnectKey == "" {
if len(devices) > 1 {
return nil, errors.Wrap(code.DeviceConnectionError,
"more than one device connected, please specify the serial")
}
harmonyDevice = &devices[0]
deviceConfig.ConnectKey = harmonyDevice.Serial()
log.Warn().Str("serial", deviceConfig.ConnectKey).
Msg("harmony ConnectKey is not specified, select the attached one")
} else {
for _, d := range devices {
if d.Serial() == deviceConfig.ConnectKey {
harmonyDevice = &d
break
}
}
if harmonyDevice == nil {
return nil, errors.Wrapf(code.DeviceConnectionError,
"harmony device %s not attached", harmonyDevice.Serial())
}
}
device = &HarmonyDevice{
Options: deviceConfig,
Device: harmonyDevice,
}
log.Info().Str("connectKey", device.Options.ConnectKey).Msg("init harmony device")
// setup device
if err := device.Setup(); err != nil {
return nil, errors.Wrap(err, "setup harmony device failed")
}
return device, nil
}
func (dev *HarmonyDevice) Setup() error {
return nil
}
func (dev *HarmonyDevice) Teardown() error {
return nil
}
func (dev *HarmonyDevice) UUID() string {
return dev.Options.ConnectKey
}
func (dev *HarmonyDevice) LogEnabled() bool {
return dev.Options.LogOn
}
func (dev *HarmonyDevice) Install(appPath string, opts ...option.InstallOption) error {
return nil
}
func (dev *HarmonyDevice) Uninstall(packageName string) error {
return nil
}
func (dev *HarmonyDevice) GetPackageInfo(packageName string) (types.AppInfo, error) {
log.Warn().Msg("get package info not implemented for harmony device, skip")
return types.AppInfo{}, nil
}
func (dev *HarmonyDevice) NewDriver() (IDriver, error) {
// init harmony driver
driver, err := NewHDCDriver(dev)
if err != nil {
return nil, errors.Wrap(err, "init harmony driver failed")
}
return driver, nil
}
func (dev *HarmonyDevice) ScreenShot() (*bytes.Buffer, error) {
return nil, errors.New("not implemented")
}

View File

@@ -1,298 +0,0 @@
package uixt
import (
"bytes"
"fmt"
"os"
"regexp"
"time"
"code.byted.org/iesqa/ghdc"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
func NewHDCDriver(device *HarmonyDevice) (*HDCDriver, error) {
driver := &HDCDriver{
Device: device,
}
driver.InitSession(nil)
uiDriver, err := ghdc.NewUIDriver(*device.Device)
if err != nil {
log.Error().Err(err).Msg("failed to new harmony ui driver")
return nil, err
}
driver.uiDriver = uiDriver
// setup driver
if err := driver.Setup(); err != nil {
return nil, err
}
return driver, nil
}
type HDCDriver struct {
Device *HarmonyDevice
Session *DriverSession
points []ExportPoint
uiDriver *ghdc.UIDriver
}
func (hd *HDCDriver) InitSession(capabilities option.Capabilities) error {
return nil
}
func (hd *HDCDriver) DeleteSession() error {
return types.ErrDriverNotImplemented
}
func (hd *HDCDriver) GetSession() *DriverSession {
return hd.Session
}
func (hd *HDCDriver) Status() (types.DeviceStatus, error) {
return types.DeviceStatus{}, types.ErrDriverNotImplemented
}
func (hd *HDCDriver) GetDevice() IDevice {
return hd.Device
}
func (hd *HDCDriver) DeviceInfo() (types.DeviceInfo, error) {
return types.DeviceInfo{}, types.ErrDriverNotImplemented
}
func (hd *HDCDriver) BatteryInfo() (types.BatteryInfo, error) {
return types.BatteryInfo{}, types.ErrDriverNotImplemented
}
func (hd *HDCDriver) WindowSize() (size types.Size, err error) {
display, err := hd.uiDriver.GetDisplaySize()
if err != nil {
log.Error().Err(err).Msg("failed to get window size")
return types.Size{}, err
}
size.Width = display.Width
size.Height = display.Height
return size, err
}
func (hd *HDCDriver) Home() error {
return hd.uiDriver.PressKey(ghdc.KEYCODE_HOME)
}
type PowerStatus string
const (
POWER_STATUS_SUSPEND PowerStatus = "POWER_STATUS_SUSPEND"
POWER_STATUS_OFF PowerStatus = "POWER_STATUS_OFF"
POWER_STATUS_ON PowerStatus = "POWER_STATUS_ON"
)
func (hd *HDCDriver) Unlock() (err error) {
// Todo 检查是否锁屏 hdc shell hidumper -s RenderService -a screen
screenInfo, err := hd.Device.RunShellCommand("hidumper", "-s", "RenderService", "-a", "screen")
if err != nil {
return err
}
re := regexp.MustCompile(`powerstatus=([\w_]+)`)
match := re.FindStringSubmatch(screenInfo)
log.Info().Msg("screen info: " + screenInfo)
if len(match) <= 1 {
return fmt.Errorf("failed to unlock; failed to find powerstatus")
}
if match[1] == string(POWER_STATUS_SUSPEND) || match[1] == string(POWER_STATUS_OFF) {
err = hd.uiDriver.PressPowerKey()
if err != nil {
return err
}
}
return hd.Swipe(500, 1500, 500, 500)
}
func (hd *HDCDriver) AppLaunch(packageName string) error {
// Todo
return types.ErrDriverNotImplemented
}
func (hd *HDCDriver) AppTerminate(packageName string) (bool, error) {
_, err := hd.Device.RunShellCommand("aa", "force-stop", packageName)
if err != nil {
log.Error().Err(err).Msg("failed to terminal app")
return false, err
}
return true, nil
}
func (hd *HDCDriver) ForegroundInfo() (app types.AppInfo, err error) {
// Todo
return types.AppInfo{}, types.ErrDriverNotImplemented
}
func (hd *HDCDriver) Orientation() (orientation types.Orientation, err error) {
return types.OrientationPortrait, nil
}
func (hd *HDCDriver) TapXY(x, y float64, opts ...option.ActionOption) error {
absX, absY, err := convertToAbsolutePoint(hd, x, y)
if err != nil {
return err
}
return hd.TapAbsXY(absX, absY, opts...)
}
func (hd *HDCDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
actionOptions := option.NewActionOptions(opts...)
x, y = actionOptions.ApplyOffset(x, y)
if actionOptions.Identifier != "" {
startTime := int(time.Now().UnixMilli())
hd.points = append(hd.points, ExportPoint{Start: startTime, End: startTime + 100, Ext: actionOptions.Identifier, RunTime: 100})
}
return hd.uiDriver.InjectGesture(
ghdc.NewGesture().Start(ghdc.Point{X: int(x), Y: int(y)}).Pause(100))
}
func (hd *HDCDriver) DoubleTap(x, y float64, opts ...option.ActionOption) error {
return types.ErrDriverNotImplemented
}
func (hd *HDCDriver) TouchAndHold(x, y float64, opts ...option.ActionOption) (err error) {
return types.ErrDriverNotImplemented
}
func (hd *HDCDriver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
return types.ErrDriverNotImplemented
}
// Swipe works like Drag, but `pressForDuration` value is 0
func (hd *HDCDriver) Swipe(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
var err error
actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(hd, fromX, fromY, toX, toY)
if err != nil {
return err
}
duration := 200
if actionOptions.PressDuration > 0 {
duration = int(actionOptions.PressDuration * 1000)
}
if actionOptions.Identifier != "" {
startTime := int(time.Now().UnixMilli())
hd.points = append(hd.points, ExportPoint{Start: startTime, End: startTime + 100, Ext: actionOptions.Identifier, RunTime: 100})
}
return hd.uiDriver.InjectGesture(
ghdc.NewGesture().Start(ghdc.Point{X: int(fromX), Y: int(fromY)}).
MoveTo(ghdc.Point{X: int(toX), Y: int(toY)}, duration))
}
func (hd *HDCDriver) SetIme(ime string) error {
return types.ErrDriverNotImplemented
}
func (hd *HDCDriver) Input(text string, opts ...option.ActionOption) error {
return hd.uiDriver.InputText(text)
}
func (hd *HDCDriver) AppClear(packageName string) error {
return types.ErrDriverNotImplemented
}
func (hd *HDCDriver) Back() error {
return hd.uiDriver.PressBack()
}
func (hd *HDCDriver) Backspace(count int, opts ...option.ActionOption) (err error) {
return nil
}
func (hd *HDCDriver) PressHarmonyKeyCode(keyCode ghdc.KeyCode) (err error) {
return hd.uiDriver.PressKey(keyCode)
}
func (hd *HDCDriver) ScreenShot(opts ...option.ActionOption) (*bytes.Buffer, error) {
tempDir := os.TempDir()
screenshotPath := fmt.Sprintf("%s/screenshot_%d.png", tempDir, time.Now().Unix())
err := hd.uiDriver.Screenshot(screenshotPath)
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"hdc screencap failed %v", err)
}
defer func() {
_ = os.Remove(screenshotPath)
}()
raw, err := os.ReadFile(screenshotPath)
if err != nil {
log.Error().Err(err).Msg("failed to screenshot")
return nil, err
}
rawBuffer := bytes.NewBuffer(raw)
actionOptions := option.NewActionOptions(opts...)
if actionOptions.ScreenShotFileName != "" {
// save screenshot to file
path, err := saveScreenShot(rawBuffer, actionOptions.ScreenShotFileName)
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"save screenshot file failed %v", err)
}
log.Info().Str("path", path).Msg("screenshot saved")
}
return rawBuffer, nil
}
func (hd *HDCDriver) Source(srcOpt ...option.SourceOption) (string, error) {
return "", nil
}
func (hd *HDCDriver) StartCaptureLog(identifier ...string) (err error) {
return types.ErrDriverNotImplemented
}
func (hd *HDCDriver) StopCaptureLog() (result interface{}, err error) {
// defer clear(hd.points)
return hd.points, nil
}
func (hd *HDCDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) {
return "", nil
}
func (hd *HDCDriver) Setup() error {
return nil
}
func (hd *HDCDriver) TearDown() error {
return nil
}
func (hd *HDCDriver) Rotation() (rotation types.Rotation, err error) {
err = types.ErrDriverNotImplemented
return
}
func (hd *HDCDriver) SetRotation(rotation types.Rotation) (err error) {
err = types.ErrDriverNotImplemented
return
}
func (hd *HDCDriver) PushImage(localPath string) error {
log.Warn().Msg("PushImage not implemented in HDCDriver")
return nil
}
func (hd *HDCDriver) ClearImages() error {
log.Warn().Msg("ClearImages not implemented in HDCDriver")
return nil
}

View File

@@ -1,83 +0,0 @@
//go:build localtest
package uixt
import (
"fmt"
"testing"
"github.com/httprunner/httprunner/v5/pkg/uixt/ai"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupHDCDriverExt(t *testing.T) *XTDriver {
device, err := NewHarmonyDevice()
require.Nil(t, err)
hdcDriver, err := NewHDCDriver(device)
require.Nil(t, err)
return NewXTDriver(hdcDriver, ai.WithCVService(ai.CVServiceTypeVEDEM))
}
func TestWindowSize(t *testing.T) {
driver := setupHDCDriverExt(t)
size, err := driver.WindowSize()
assert.Nil(t, err)
t.Log(fmt.Sprintf("width: %d, height: %d", size.Width, size.Height))
}
func TestHarmonyTap(t *testing.T) {
driver := setupHDCDriverExt(t)
err := driver.TapAbsXY(200, 2000)
assert.Nil(t, err)
}
func TestHarmonySwipe(t *testing.T) {
driver := setupHDCDriverExt(t)
err := driver.Swipe(0.5, 0.5, 0.1, 0.5)
assert.Nil(t, err)
}
func TestHarmonyInput(t *testing.T) {
driver := setupHDCDriverExt(t)
err := driver.Input("test")
assert.Nil(t, err)
}
func TestHomeScreen(t *testing.T) {
driver := setupHDCDriverExt(t)
err := driver.Home()
assert.Nil(t, err)
}
func TestUnlock(t *testing.T) {
driver := setupHDCDriverExt(t)
err := driver.Unlock()
assert.Nil(t, err)
}
func TestPressBack(t *testing.T) {
driver := setupHDCDriverExt(t)
err := driver.Back()
assert.Nil(t, err)
}
func TestScreenshot(t *testing.T) {
driver := setupHDCDriverExt(t)
screenshot, err := driver.ScreenShot()
assert.Nil(t, err)
t.Log(screenshot)
}
func TestLaunch(t *testing.T) {
driver := setupHDCDriverExt(t)
err := driver.AppLaunch("")
assert.Nil(t, err)
}
func TestForegroundApp(t *testing.T) {
driver := setupHDCDriverExt(t)
appInfo, err := driver.ForegroundInfo()
assert.Nil(t, err)
t.Log(appInfo)
}

View File

@@ -1,519 +0,0 @@
package uixt
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"time"
"github.com/Masterminds/semver"
"github.com/danielpaulus/go-ios/ios"
"github.com/danielpaulus/go-ios/ios/deviceinfo"
"github.com/danielpaulus/go-ios/ios/diagnostics"
"github.com/danielpaulus/go-ios/ios/forward"
"github.com/danielpaulus/go-ios/ios/imagemounter"
"github.com/danielpaulus/go-ios/ios/installationproxy"
"github.com/danielpaulus/go-ios/ios/instruments"
"github.com/danielpaulus/go-ios/ios/testmanagerd"
"github.com/danielpaulus/go-ios/ios/tunnel"
"github.com/danielpaulus/go-ios/ios/zipconduit"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
func StartTunnel(ctx context.Context, recordsPath string, tunnelInfoPort int, userspaceTUN bool) (err error) {
pm, err := tunnel.NewPairRecordManager(recordsPath)
if err != nil {
return err
}
tm := tunnel.NewTunnelManager(pm, userspaceTUN)
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
err := tm.UpdateTunnels(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to update tunnels")
}
}
}()
go func() {
err := tunnel.ServeTunnelInfo(tm, tunnelInfoPort)
if err != nil {
log.Error().Err(err).Msg("failed to start tunnel server")
}
}()
log.Info().Msg("Tunnel server started")
return nil
}
var tunnelManager *tunnel.TunnelManager = nil
func RebootTunnel() (err error) {
if tunnelManager != nil {
_ = tunnelManager.Close()
}
return StartTunnel(context.Background(), os.TempDir(), ios.HttpApiPort(), true)
}
func NewIOSDevice(opts ...option.IOSDeviceOption) (device *IOSDevice, err error) {
deviceOptions := option.NewIOSDeviceOptions(opts...)
// get all attached ios devices
devices, err := ios.ListDevices()
if err != nil {
return nil, errors.Wrap(code.DeviceConnectionError, err.Error())
}
if len(devices.DeviceList) == 0 {
return nil, errors.Wrapf(code.DeviceConnectionError,
"no attached ios devices")
}
// filter device by udid
var iosDevice *ios.DeviceEntry
if deviceOptions.UDID == "" {
if len(devices.DeviceList) > 1 {
return nil, errors.Wrap(code.DeviceConnectionError,
"more than one device connected, please specify the udid")
}
iosDevice = &devices.DeviceList[0]
deviceOptions.UDID = iosDevice.Properties.SerialNumber
log.Warn().Str("udid", deviceOptions.UDID).
Msg("ios UDID is not specified, select the attached one")
} else {
for _, d := range devices.DeviceList {
if d.Properties.SerialNumber == deviceOptions.UDID {
iosDevice = &d
break
}
}
if iosDevice == nil {
return nil, errors.Wrapf(code.DeviceConnectionError,
"ios device %s not attached", deviceOptions.UDID)
}
}
device = &IOSDevice{
DeviceEntry: *iosDevice,
Options: deviceOptions,
listeners: make(map[int]*forward.ConnListener),
}
log.Info().Str("udid", device.Options.UDID).Msg("init ios device")
// setup device
if err := device.Setup(); err != nil {
return nil, errors.Wrap(err, "setup ios device failed")
}
return device, nil
}
type IOSDevice struct {
ios.DeviceEntry
Options *option.IOSDeviceOptions
listeners map[int]*forward.ConnListener
}
type DeviceDetail struct {
DeviceName string `json:"deviceName,omitempty"`
DeviceClass string `json:"deviceClass,omitempty"`
ProductVersion string `json:"productVersion,omitempty"`
ProductType string `json:"productType,omitempty"`
ProductName string `json:"productName,omitempty"`
PasswordProtected bool `json:"passwordProtected,omitempty"`
ModelNumber string `json:"modelNumber,omitempty"`
SerialNumber string `json:"serialNumber,omitempty"`
SIMStatus string `json:"simStatus,omitempty"`
PhoneNumber string `json:"phoneNumber,omitempty"`
CPUArchitecture string `json:"cpuArchitecture,omitempty"`
ProtocolVersion string `json:"protocolVersion,omitempty"`
RegionInfo string `json:"regionInfo,omitempty"`
TimeZone string `json:"timeZone,omitempty"`
UniqueDeviceID string `json:"uniqueDeviceID,omitempty"`
WiFiAddress string `json:"wifiAddress,omitempty"`
BuildVersion string `json:"buildVersion,omitempty"`
}
type ApplicationType string
const (
ApplicationTypeSystem ApplicationType = "System"
ApplicationTypeUser ApplicationType = "User"
ApplicationTypeInternal ApplicationType = "internal"
ApplicationTypeAny ApplicationType = "Any"
)
func (dev *IOSDevice) Setup() error {
version, err := dev.getVersion()
if err != nil {
return err
}
if version.GreaterThan(semver.MustParse("17.4.0")) {
info, err := tunnel.TunnelInfoForDevice(dev.DeviceEntry.Properties.SerialNumber, ios.HttpApiHost(), ios.HttpApiPort())
if err != nil {
return err
}
dev.DeviceEntry.UserspaceTUNPort = info.UserspaceTUNPort
dev.DeviceEntry.UserspaceTUN = info.UserspaceTUN
rsdService, err := ios.NewWithAddrPortDevice(info.Address, info.RsdPort, dev.DeviceEntry)
defer rsdService.Close()
rsdProvider, err := rsdService.Handshake()
if err != nil {
return err
}
device, err := ios.GetDeviceWithAddress(dev.DeviceEntry.Properties.SerialNumber, info.Address, rsdProvider)
if err != nil {
return err
}
device.UserspaceTUN = dev.DeviceEntry.UserspaceTUN
device.UserspaceTUNPort = dev.DeviceEntry.UserspaceTUNPort
dev.DeviceEntry = device
}
return nil
}
func (dev *IOSDevice) Teardown() error {
for _, listener := range dev.listeners {
_ = listener.Close()
}
return nil
}
func (dev *IOSDevice) UUID() string {
return dev.Options.UDID
}
func (dev *IOSDevice) LogEnabled() bool {
return dev.Options.LogOn
}
func (dev *IOSDevice) getAppInfo(packageName string) (appInfo types.AppInfo, err error) {
apps, err := dev.ListApps(ApplicationTypeAny)
if err != nil {
return types.AppInfo{}, err
}
for _, app := range apps {
if app.CFBundleIdentifier == packageName {
appInfo.BundleId = app.CFBundleIdentifier
appInfo.AppName = app.CFBundleName
appInfo.PackageName = app.CFBundleIdentifier
appInfo.VersionName = app.CFBundleShortVersionString
appInfo.VersionCode = app.CFBundleVersion
return appInfo, err
}
}
return types.AppInfo{}, fmt.Errorf("not found App by bundle id: %s", packageName)
}
func (dev *IOSDevice) NewDriver() (driver IDriver, err error) {
wdaDriver, err := NewWDADriver(dev)
if err != nil {
return nil, errors.Wrap(err, "failed to init WDA driver")
}
settings, err := wdaDriver.SetAppiumSettings(map[string]interface{}{
"snapshotMaxDepth": dev.Options.SnapshotMaxDepth,
"acceptAlertButtonSelector": dev.Options.AcceptAlertButtonSelector,
})
if err != nil {
return nil, errors.Wrap(err, "failed to set appium WDA settings")
}
log.Info().Interface("appiumWDASettings", settings).Msg("set appium WDA settings")
if dev.Options.ResetHomeOnStartup {
log.Info().Msg("go back to home screen")
if err = wdaDriver.Home(); err != nil {
return nil, errors.Wrap(code.MobileUIDriverError,
fmt.Sprintf("go back to home screen failed: %v", err))
}
}
if dev.Options.LogOn {
err = wdaDriver.StartCaptureLog("hrp_wda_log")
if err != nil {
return nil, err
}
}
return wdaDriver, nil
}
func (dev *IOSDevice) Install(appPath string, opts ...option.InstallOption) (err error) {
installOpts := option.NewInstallOptions(opts...)
for i := 0; i <= installOpts.RetryTimes; i++ {
var conn *zipconduit.Connection
conn, err = zipconduit.New(dev.DeviceEntry)
if err != nil {
return err
}
defer conn.Close()
err = conn.SendFile(appPath)
if err != nil {
log.Error().Err(err).Msg(fmt.Sprintf("failed to install app Retry time %d", i))
}
if err == nil {
return nil
}
}
return err
}
func (dev *IOSDevice) Uninstall(bundleId string) error {
svc, err := installationproxy.New(dev.DeviceEntry)
if err != nil {
return err
}
defer svc.Close()
err = svc.Uninstall(bundleId)
if err != nil {
return err
}
return nil
}
func (dev *IOSDevice) Forward(localPort, remotePort int) error {
if dev.listeners[localPort] != nil {
log.Warn().Msg(fmt.Sprintf("local port :%d is already in use", localPort))
_ = dev.listeners[localPort].Close()
}
listener, err := forward.Forward(dev.DeviceEntry, uint16(localPort), uint16(remotePort))
if err != nil {
log.Error().Err(err).Msg(fmt.Sprintf("failed to forward %d to %d", localPort, remotePort))
return err
}
dev.listeners[localPort] = listener
return nil
}
func (dev *IOSDevice) GetDeviceInfo() (*DeviceDetail, error) {
deviceInfo, err := deviceinfo.NewDeviceInfo(dev.DeviceEntry)
if err != nil {
log.Error().Err(err).Msg("failed to get device info")
return nil, err
}
defer deviceInfo.Close()
info, err := deviceInfo.GetDisplayInfo()
if err != nil {
log.Error().Err(err).Msg("failed to get device info")
return nil, err
}
jsonData, err := json.Marshal(info)
if err != nil {
return nil, err
}
// 将 JSON 反序列化为结构体
detail := new(DeviceDetail)
err = json.Unmarshal(jsonData, &detail)
if err != nil {
return nil, err
}
return detail, err
}
func (dev *IOSDevice) ListApps(appType ApplicationType) (apps []installationproxy.AppInfo, err error) {
svc, _ := installationproxy.New(dev.DeviceEntry)
defer svc.Close()
switch appType {
case ApplicationTypeSystem:
apps, err = svc.BrowseSystemApps()
case ApplicationTypeAny:
apps, err = svc.BrowseAllApps()
case ApplicationTypeInternal:
apps, err = svc.BrowseFileSharingApps()
case ApplicationTypeUser:
apps, err = svc.BrowseUserApps()
}
if err != nil {
log.Error().Err(err).Msg("failed to list ios apps")
return nil, err
}
return apps, nil
}
func (dev *IOSDevice) ScreenShot() (*bytes.Buffer, error) {
screenshotService, err := instruments.NewScreenshotService(dev.DeviceEntry)
if err != nil {
log.Error().Err(err).Msg("Starting screenshot service failed")
return nil, err
}
defer screenshotService.Close()
imageBytes, err := screenshotService.TakeScreenshot()
if err != nil {
log.Error().Err(err).Msg("failed to task screenshot")
return nil, err
}
return bytes.NewBuffer(imageBytes), nil
}
func (dev *IOSDevice) GetAppInfo(packageName string) (appInfo installationproxy.AppInfo, err error) {
svc, _ := installationproxy.New(dev.DeviceEntry)
defer svc.Close()
apps, err := svc.BrowseAllApps()
if err != nil {
log.Error().Err(err).Msg("failed to list ios apps")
return installationproxy.AppInfo{}, err
}
for _, app := range apps {
if app.CFBundleIdentifier == packageName {
return app, nil
}
}
return installationproxy.AppInfo{}, nil
}
func (dev *IOSDevice) ListImages() (images []string, err error) {
conn, err := imagemounter.NewImageMounter(dev.DeviceEntry)
if err != nil {
return nil, errors.Wrap(code.DeviceConnectionError, err.Error())
}
defer conn.Close()
signatures, err := conn.ListImages()
if err != nil {
return nil, errors.Wrap(code.DeviceConnectionError, err.Error())
}
for _, sig := range signatures {
images = append(images, fmt.Sprintf("%x", sig))
}
return
}
func (dev *IOSDevice) MountImage(imagePath string) (err error) {
log.Info().Str("imagePath", imagePath).Msg("mount ios developer image")
conn, err := imagemounter.NewImageMounter(dev.DeviceEntry)
if err != nil {
return errors.Wrap(code.DeviceConnectionError, err.Error())
}
defer conn.Close()
err = conn.MountImage(imagePath)
if err != nil {
return errors.Wrapf(code.DeviceConnectionError,
"mount ios developer image failed: %v", err)
}
log.Info().Str("imagePath", imagePath).Msg("mount ios developer image success")
return nil
}
func (dev *IOSDevice) AutoMountImage(baseDir string) (err error) {
log.Info().Str("baseDir", baseDir).Msg("auto mount ios developer image")
imagePath, err := imagemounter.DownloadImageFor(dev.DeviceEntry, baseDir)
if err != nil {
return errors.Wrapf(code.DeviceConnectionError,
"download ios developer image failed: %v", err)
}
return dev.MountImage(imagePath)
}
func (dev *IOSDevice) RunXCTest(ctx context.Context, bundleID, testRunnerBundleID, xctestConfig string) (err error) {
log.Info().Str("bundleID", bundleID).
Str("testRunnerBundleID", testRunnerBundleID).
Str("xctestConfig", xctestConfig).
Msg("run xctest")
listener := testmanagerd.NewTestListener(io.Discard, io.Discard, os.TempDir())
config := testmanagerd.TestConfig{
BundleId: bundleID,
TestRunnerBundleId: testRunnerBundleID,
XctestConfigName: xctestConfig,
Device: dev.DeviceEntry,
Listener: listener,
}
_, err = testmanagerd.RunTestWithConfig(ctx, config)
if err != nil {
log.Error().Err(err).Msg("run xctest failed")
return err
}
return nil
}
func (dev *IOSDevice) RunXCTestDaemon(ctx context.Context, bundleID, testRunnerBundleID, xctestConfig string) {
ctx, stopWda := context.WithCancel(ctx)
go func() {
err := dev.RunXCTest(ctx, bundleID, testRunnerBundleID, xctestConfig)
if err != nil {
log.Error().Err(err).Msg("wda ended")
}
stopWda()
}()
}
func (dev *IOSDevice) getVersion() (version *semver.Version, err error) {
version, err = ios.GetProductVersion(dev.DeviceEntry)
if err != nil {
log.Error().Err(err).Msg("failed to get version")
return nil, err
}
log.Info().Str("version", version.String()).Msg("get ios device version")
return version, nil
}
func (dev *IOSDevice) ListProcess(applicationsOnly bool) (processList []instruments.ProcessInfo, err error) {
service, err := instruments.NewDeviceInfoService(dev.DeviceEntry)
if err != nil {
log.Error().Err(err).Msg("failed to list process")
return
}
defer service.Close()
processList, err = service.ProcessList()
if applicationsOnly {
var applicationProcessList []instruments.ProcessInfo
for _, processInfo := range processList {
if processInfo.IsApplication {
applicationProcessList = append(applicationProcessList, processInfo)
}
}
processList = applicationProcessList
}
return
}
func (dev *IOSDevice) Reboot() error {
err := diagnostics.Reboot(dev.DeviceEntry)
if err != nil {
log.Error().Err(err).Msg("failed to reboot device")
return err
}
return nil
}
func (dev *IOSDevice) GetPackageInfo(packageName string) (types.AppInfo, error) {
svc, err := installationproxy.New(dev.DeviceEntry)
if err != nil {
return types.AppInfo{}, errors.Wrap(code.DeviceGetInfoError, err.Error())
}
defer svc.Close()
apps, err := svc.BrowseAllApps()
if err != nil {
return types.AppInfo{}, errors.Wrap(code.DeviceGetInfoError, err.Error())
}
for _, app := range apps {
if app.CFBundleIdentifier != packageName {
continue
}
appInfo := types.AppInfo{
Name: app.CFBundleName,
AppBaseInfo: types.AppBaseInfo{
BundleId: app.CFBundleIdentifier,
PackageName: app.CFBundleIdentifier,
VersionName: app.CFBundleShortVersionString,
VersionCode: app.CFBundleVersion,
AppName: app.CFBundleDisplayName,
AppPath: app.Path,
},
}
log.Info().Interface("appInfo", appInfo).Msg("get package info")
return appInfo, nil
}
return types.AppInfo{}, errors.Wrap(code.DeviceAppNotInstalled,
fmt.Sprintf("%s not found", packageName))
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,314 +0,0 @@
//go:build localtest
package uixt
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/httprunner/httprunner/v5/pkg/uixt/ai"
"github.com/httprunner/httprunner/v5/pkg/uixt/option"
"github.com/httprunner/httprunner/v5/pkg/uixt/types"
)
func setupWDADriverExt(t *testing.T) *XTDriver {
device, err := NewIOSDevice(
option.WithWDAPort(8700),
option.WithWDAMjpegPort(8800),
option.WithWDALogOn(true))
require.Nil(t, err)
driver, err := device.NewDriver()
require.Nil(t, err)
return NewXTDriver(driver, ai.WithCVService(ai.CVServiceTypeVEDEM))
}
func TestDevice_IOS_Install(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.GetDevice().Install("xxx.ipa",
option.WithRetryTimes(5))
assert.Nil(t, err)
}
func TestDriver_WDA_LazySetup(t *testing.T) {
device, err := NewIOSDevice(
option.WithWDAPort(8700),
option.WithWDAMjpegPort(8800),
option.WithLazySetup(true))
require.Nil(t, err)
driver, err := NewWDADriver(device)
require.Nil(t, err)
err = driver.TapAbsXY(100, 200)
assert.Nil(t, err)
err = driver.PressButton(types.DeviceButtonHome)
assert.Nil(t, err)
err = driver.TapXY(0.5, 0.5)
assert.Nil(t, err)
}
func TestDevice_IOS_New(t *testing.T) {
device, err := NewIOSDevice(
option.WithWDAPort(8700),
option.WithWDAMjpegPort(8800))
require.Nil(t, err)
device, _ = NewIOSDevice(option.WithUDID("xxxx"))
if device != nil {
t.Log(device)
}
device, _ = NewIOSDevice(
option.WithWDAPort(8700),
option.WithWDAMjpegPort(8800))
if device != nil {
t.Log(device)
}
device, _ = NewIOSDevice(
option.WithUDID("xxxx"),
option.WithWDAPort(8700),
option.WithWDAMjpegPort(8800))
if device != nil {
t.Log(device)
}
}
func TestDevice_IOS_GetPackageInfo(t *testing.T) {
device, err := NewIOSDevice(option.WithWDAPort(8700))
require.Nil(t, err)
appInfo, err := device.GetPackageInfo("com.ss.iphone.ugc.Aweme")
assert.Nil(t, err)
assert.Equal(t, "com.ss.iphone.ugc.Aweme", appInfo.PackageName)
t.Logf("%+v", appInfo)
}
func TestDriver_WDA_DeviceScaleRatio(t *testing.T) {
driver := setupWDADriverExt(t)
scaleRatio, err := driver.IDriver.(*WDADriver).Scale()
require.Nil(t, err)
t.Logf("%+v", scaleRatio)
}
func TestDriver_WDA_DeleteSession(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.DeleteSession()
assert.Nil(t, err)
}
func TestDriver_WDA_HealthCheck(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.IDriver.(*WDADriver).HealthCheck()
assert.Nil(t, err)
}
func TestDriver_WDA_GetAppiumSettings(t *testing.T) {
driver := setupWDADriverExt(t)
settings, err := driver.IDriver.(*WDADriver).GetAppiumSettings()
assert.Nil(t, err)
t.Logf("%+v", settings)
}
func TestDriver_WDA_SetAppiumSettings(t *testing.T) {
driver := setupWDADriverExt(t)
const _acceptAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'允许','好','仅在使用应用期间','暂不'}`]"
const _dismissAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'不允许','暂不'}`]"
key := "acceptAlertButtonSelector"
value := _acceptAlertButtonSelector
// settings, err := driver.SetAppiumSettings(map[string]interface{}{"dismissAlertButtonSelector": "暂不"})
settings, err := driver.IDriver.(*WDADriver).SetAppiumSettings(map[string]interface{}{key: value})
assert.Nil(t, err)
assert.Equal(t, settings[key], value)
}
func TestDriver_WDA_IsWdaHealthy(t *testing.T) {
driver := setupWDADriverExt(t)
healthy, err := driver.IDriver.(*WDADriver).IsHealthy()
assert.Nil(t, err)
assert.True(t, healthy)
}
func TestDriver_WDA_Status(t *testing.T) {
driver := setupWDADriverExt(t)
status, err := driver.Status()
assert.Nil(t, err)
assert.True(t, status.Ready)
}
func TestDriver_WDA_DeviceInfo(t *testing.T) {
driver := setupWDADriverExt(t)
info, err := driver.DeviceInfo()
assert.Nil(t, err)
assert.NotEmpty(t, info.Model)
}
func TestDriver_WDA_BatteryInfo(t *testing.T) {
driver := setupWDADriverExt(t)
batteryInfo, err := driver.BatteryInfo()
assert.Nil(t, err)
t.Log(batteryInfo)
}
func TestDriver_WDA_WindowSize(t *testing.T) {
driver := setupWDADriverExt(t)
size, err := driver.WindowSize()
assert.Nil(t, err)
t.Log(size)
}
func TestDriver_WDA_Screen(t *testing.T) {
driver := setupWDADriverExt(t)
screen, err := driver.IDriver.(*WDADriver).Screen()
assert.Nil(t, err)
t.Log(screen)
}
func TestDriver_WDA_Home(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.Home()
assert.Nil(t, err)
}
func TestDriver_WDA_AppLaunchTerminate(t *testing.T) {
driver := setupWDADriverExt(t)
bundleId := "com.apple.Preferences"
err := driver.AppLaunch(bundleId)
assert.Nil(t, err)
time.Sleep(2 * time.Second)
_, err = driver.AppTerminate(bundleId)
assert.Nil(t, err)
}
func TestDriver_WDA_TapXY(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.TapXY(0.2, 0.2)
assert.Nil(t, err)
}
func TestDriver_WDA_DoubleTapXY(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.DoubleTap(0.2, 0.2)
assert.Nil(t, err)
}
func TestDriver_WDA_TouchAndHold(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.TouchAndHold(0.2, 0.2)
assert.Nil(t, err)
}
func TestDriver_WDA_Drag(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.Drag(0.8, 0.5, 0.2, 0.5,
option.WithDuration(0.5))
assert.Nil(t, err)
}
func TestDriver_WDA_Swipe(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.Swipe(0.8, 0.5, 0.2, 0.5)
assert.Nil(t, err)
}
func TestDriver_WDA_Input(t *testing.T) {
driver := setupWDADriverExt(t)
driver.StartCaptureLog("hrp_wda_log")
err := driver.Input("test中文", option.WithIdentifier("test"))
assert.Nil(t, err)
result, err := driver.StopCaptureLog()
assert.Nil(t, err)
t.Log(result)
}
func TestDriver_WDA_PressButton(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.IDriver.(*WDADriver).PressButton(types.DeviceButtonVolumeUp)
assert.Nil(t, err)
time.Sleep(time.Second * 1)
err = driver.IDriver.(*WDADriver).PressButton(types.DeviceButtonVolumeDown)
assert.Nil(t, err)
time.Sleep(time.Second * 1)
err = driver.IDriver.(*WDADriver).PressButton(types.DeviceButtonHome)
assert.Nil(t, err)
}
func TestDriver_WDA_ScreenShot(t *testing.T) {
driver := setupWDADriverExt(t)
// without save file
screenshot, err := driver.ScreenShot()
assert.Nil(t, err)
_ = screenshot
// save file
screenshot, err = driver.ScreenShot(option.WithScreenShotFileName("123"))
assert.Nil(t, err)
_ = screenshot
path, err := saveScreenShot(screenshot, "1234")
assert.Nil(t, err)
defer os.Remove(path)
t.Logf("save screenshot to %s", path)
}
func TestDriver_WDA_Source(t *testing.T) {
driver := setupWDADriverExt(t)
var source string
var err error
source, err = driver.Source()
assert.Nil(t, err)
source, err = driver.Source(option.WithFormat(option.SourceFormatJSON))
assert.Nil(t, err)
source, err = driver.Source(option.WithFormat(option.SourceFormatDescription))
assert.Nil(t, err)
source, err = driver.Source(
option.WithFormat(option.SourceFormatXML),
option.WithExcludedAttributes([]string{"label", "type", "index"}))
assert.Nil(t, err)
t.Logf("source: %s", source)
}
func TestDriver_WDA_GetForegroundApp(t *testing.T) {
driver := setupWDADriverExt(t)
app, err := driver.ForegroundInfo()
assert.Nil(t, err)
t.Log(app)
}
func TestDriver_WDA_AccessibleSource(t *testing.T) {
driver := setupWDADriverExt(t)
source, err := driver.IDriver.(*WDADriver).AccessibleSource()
assert.Nil(t, err)
t.Log(source)
}
func TestDriver_WDA_ScreenRecord(t *testing.T) {
driver := setupWDADriverExt(t)
path, err := driver.ScreenRecord(5 * time.Second)
assert.Nil(t, err)
t.Log(path)
}
func TestDriver_WDA_Backspace(t *testing.T) {
driver := setupWDADriverExt(t)
err := driver.Backspace(3)
assert.Nil(t, err)
}

View File

@@ -1,301 +0,0 @@
package option
import (
"math/rand/v2"
"github.com/httprunner/httprunner/v5/internal/builtin"
)
type ActionOptions struct {
// log
Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` // used to identify the action in log
// control related
MaxRetryTimes int `json:"max_retry_times,omitempty" yaml:"max_retry_times,omitempty"` // max retry times
Interval float64 `json:"interval,omitempty" yaml:"interval,omitempty"` // interval between retries in seconds
Duration float64 `json:"duration,omitempty" yaml:"duration,omitempty"` // used to set duration in seconds
PressDuration float64 `json:"press_duration,omitempty" yaml:"press_duration,omitempty"` // used to set press duration in seconds
Steps int `json:"steps,omitempty" yaml:"steps,omitempty"` // used to set steps of action
Direction interface{} `json:"direction,omitempty" yaml:"direction,omitempty"` // used by swipe to tap text or app
Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty"` // TODO: wait timeout in seconds for mobile action
Frequency int `json:"frequency,omitempty" yaml:"frequency,omitempty"`
ScreenOptions
// set custiom options such as textview, id, description
Custom map[string]interface{} `json:"custom,omitempty" yaml:"custom,omitempty"`
}
func (o *ActionOptions) Options() []ActionOption {
options := make([]ActionOption, 0)
if o == nil {
return options
}
if o.Identifier != "" {
options = append(options, WithIdentifier(o.Identifier))
}
if o.MaxRetryTimes != 0 {
options = append(options, WithMaxRetryTimes(o.MaxRetryTimes))
}
if o.IgnoreNotFoundError {
options = append(options, WithIgnoreNotFoundError(true))
}
if o.Interval != 0 {
options = append(options, WithInterval(o.Interval))
}
if o.Duration != 0 {
options = append(options, WithDuration(o.Duration))
}
if o.PressDuration != 0 {
options = append(options, WithPressDuration(o.PressDuration))
}
if o.Steps != 0 {
options = append(options, WithSteps(o.Steps))
}
switch v := o.Direction.(type) {
case string:
options = append(options, WithDirection(v))
case []float64:
options = append(options, WithCustomDirection(
v[0], v[1],
v[2], v[3],
))
case []interface{}:
// loaded from json case
// custom direction: [fromX, fromY, toX, toY]
sx, _ := builtin.Interface2Float64(v[0])
sy, _ := builtin.Interface2Float64(v[1])
ex, _ := builtin.Interface2Float64(v[2])
ey, _ := builtin.Interface2Float64(v[3])
options = append(options, WithCustomDirection(
sx, sy,
ex, ey,
))
}
if o.Timeout != 0 {
options = append(options, WithTimeout(o.Timeout))
}
if o.Frequency != 0 {
options = append(options, WithFrequency(o.Frequency))
}
if len(o.AbsScope) == 4 {
options = append(options, WithAbsScope(
o.AbsScope[0], o.AbsScope[1], o.AbsScope[2], o.AbsScope[3]))
} else if len(o.Scope) == 4 {
options = append(options, WithScope(
o.Scope[0], o.Scope[1], o.Scope[2], o.Scope[3]))
}
if len(o.Offset) == 2 {
// for tap [x,y] offset
options = append(options, WithTapOffset(o.Offset[0], o.Offset[1]))
} else if len(o.Offset) == 4 {
// for swipe [fromX, fromY, toX, toY] offset
options = append(options, WithSwipeOffset(
o.Offset[0], o.Offset[1], o.Offset[2], o.Offset[3]))
}
if len(o.OffsetRandomRange) == 2 {
options = append(options, WithOffsetRandomRange(
o.OffsetRandomRange[0], o.OffsetRandomRange[1]))
}
if o.Regex {
options = append(options, WithRegex(true))
}
if o.Index != 0 {
options = append(options, WithIndex(o.Index))
}
if o.MatchOne {
options = append(options, WithMatchOne(true))
}
// custom options
if o.Custom != nil {
for k, v := range o.Custom {
options = append(options, WithCustomOption(k, v))
}
}
return options
}
func (o *ActionOptions) GetScreenOptions() []ActionOption {
return o.ScreenOptions.Options()
}
func (o *ActionOptions) ApplyOffset(absX, absY float64) (float64, float64) {
if len(o.Offset) == 2 {
absX += float64(o.Offset[0])
absY += float64(o.Offset[1])
}
absX += o.GenerateRandomOffset()
absY += o.GenerateRandomOffset()
return absX, absY
}
func (o *ActionOptions) GenerateRandomOffset() float64 {
if len(o.OffsetRandomRange) != 2 {
// invalid offset random range, should be [min, max]
return 0
}
minOffset := o.OffsetRandomRange[0]
maxOffset := o.OffsetRandomRange[1]
return float64(builtin.GetRandomNumber(minOffset, maxOffset)) + rand.Float64()
}
func MergeOptions(data map[string]interface{}, opts ...ActionOption) {
o := NewActionOptions(opts...)
if o.Identifier != "" {
data["log"] = map[string]interface{}{
"enable": true,
"data": o.Identifier,
}
}
if o.Steps > 0 {
data["steps"] = o.Steps
}
if _, ok := data["steps"]; !ok {
data["steps"] = 12 // default steps
}
if o.Duration > 0 {
data["duration"] = o.Duration
}
if _, ok := data["duration"]; !ok {
data["duration"] = 0 // default duration
}
if o.PressDuration > 0 {
data["pressDuration"] = o.PressDuration
}
if o.Frequency > 0 {
data["frequency"] = o.Frequency
}
if _, ok := data["frequency"]; !ok {
data["frequency"] = 10 // default frequency
}
if _, ok := data["replace"]; !ok {
data["replace"] = true // default true
}
// custom options
if o.Custom != nil {
for k, v := range o.Custom {
data[k] = v
}
}
}
func NewActionOptions(opts ...ActionOption) *ActionOptions {
actionOptions := &ActionOptions{}
for _, option := range opts {
option(actionOptions)
}
if actionOptions.MaxRetryTimes == 0 {
actionOptions.MaxRetryTimes = 1
}
return actionOptions
}
type ActionOption func(o *ActionOptions)
func WithCustomOption(key string, value interface{}) ActionOption {
return func(o *ActionOptions) {
if o.Custom == nil {
o.Custom = make(map[string]interface{})
}
o.Custom[key] = value
}
}
func WithIdentifier(identifier string) ActionOption {
return func(o *ActionOptions) {
o.Identifier = identifier
}
}
// set alias for compatibility
var WithWaitTime = WithInterval
func WithInterval(sec float64) ActionOption {
return func(o *ActionOptions) {
o.Interval = sec
}
}
func WithDuration(duration float64) ActionOption {
return func(o *ActionOptions) {
o.Duration = duration
}
}
func WithPressDuration(pressDuration float64) ActionOption {
return func(o *ActionOptions) {
o.PressDuration = pressDuration
}
}
func WithSteps(steps int) ActionOption {
return func(o *ActionOptions) {
o.Steps = steps
}
}
// WithDirection inputs direction (up, down, left, right)
func WithDirection(direction string) ActionOption {
return func(o *ActionOptions) {
o.Direction = direction
}
}
// WithCustomDirection inputs sx, sy, ex, ey
func WithCustomDirection(sx, sy, ex, ey float64) ActionOption {
return func(o *ActionOptions) {
o.Direction = []float64{sx, sy, ex, ey}
}
}
// swipe [fromX, fromY, toX, toY] with offset [offsetFromX, offsetFromY, offsetToX, offsetToY]
func WithSwipeOffset(offsetFromX, offsetFromY, offsetToX, offsetToY int) ActionOption {
return func(o *ActionOptions) {
o.Offset = []int{offsetFromX, offsetFromY, offsetToX, offsetToY}
}
}
func WithOffsetRandomRange(min, max int) ActionOption {
return func(o *ActionOptions) {
o.OffsetRandomRange = []int{min, max}
}
}
func WithFrequency(frequency int) ActionOption {
return func(o *ActionOptions) {
o.Frequency = frequency
}
}
func WithMaxRetryTimes(maxRetryTimes int) ActionOption {
return func(o *ActionOptions) {
o.MaxRetryTimes = maxRetryTimes
}
}
func WithTimeout(timeout int) ActionOption {
return func(o *ActionOptions) {
o.Timeout = timeout
}
}
func WithIgnoreNotFoundError(ignoreError bool) ActionOption {
return func(o *ActionOptions) {
o.IgnoreNotFoundError = ignoreError
}
}

View File

@@ -1,114 +0,0 @@
package option
import "github.com/httprunner/httprunner/v5/pkg/gadb"
type AndroidDeviceOptions struct {
SerialNumber string `json:"serial,omitempty" yaml:"serial,omitempty"`
LogOn bool `json:"log_on,omitempty" yaml:"log_on,omitempty"`
// adb
AdbServerHost string `json:"adb_server_host,omitempty" yaml:"adb_server_host,omitempty"`
AdbServerPort int `json:"adb_server_port,omitempty" yaml:"adb_server_port,omitempty"`
// uiautomator2
UIA2 bool `json:"uia2,omitempty" yaml:"uia2,omitempty"` // use uiautomator2
UIA2IP string `json:"uia2_ip,omitempty" yaml:"uia2_ip,omitempty"` // uiautomator2 server ip
UIA2Port int `json:"uia2_port,omitempty" yaml:"uia2_port,omitempty"` // uiautomator2 server port
UIA2ServerPackageName string `json:"uia2_server_package_name,omitempty" yaml:"uia2_server_package_name,omitempty"`
UIA2ServerTestPackageName string `json:"uia2_server_test_package_name,omitempty" yaml:"uia2_server_test_package_name,omitempty"`
}
func (dev *AndroidDeviceOptions) Options() (deviceOptions []AndroidDeviceOption) {
if dev.SerialNumber != "" {
deviceOptions = append(deviceOptions, WithSerialNumber(dev.SerialNumber))
}
if dev.UIA2 {
deviceOptions = append(deviceOptions, WithUIA2(true))
}
if dev.UIA2IP != "" {
deviceOptions = append(deviceOptions, WithUIA2IP(dev.UIA2IP))
}
if dev.UIA2Port != 0 {
deviceOptions = append(deviceOptions, WithUIA2Port(dev.UIA2Port))
}
if dev.LogOn {
deviceOptions = append(deviceOptions, WithAdbLogOn(true))
}
return
}
const (
// adb server
defaultAdbServerHost = "localhost"
defaultAdbServerPort = gadb.AdbServerPort // 5037
// uiautomator2 server
defaultUIA2ServerHost = "localhost"
defaultUIA2ServerPort = 6790
defaultUIA2ServerPackageName = "io.appium.uiautomator2.server"
defaultUIA2ServerTestPackageName = "io.appium.uiautomator2.server.test"
AdbKeyBoardPackageName = "com.android.adbkeyboard/.AdbIME"
UnicodeImePackageName = "io.appium.settings/.UnicodeIME"
)
func NewAndroidDeviceOptions(opts ...AndroidDeviceOption) *AndroidDeviceOptions {
config := &AndroidDeviceOptions{}
for _, opt := range opts {
opt(config)
}
// adb default
if config.AdbServerHost == "" {
config.AdbServerHost = defaultAdbServerHost
}
if config.AdbServerPort == 0 {
config.AdbServerPort = defaultAdbServerPort
}
// uiautomator2 default
if config.UIA2IP == "" && config.UIA2Port == 0 {
config.UIA2IP = defaultUIA2ServerHost
config.UIA2Port = defaultUIA2ServerPort
}
if config.UIA2ServerPackageName == "" {
config.UIA2ServerPackageName = defaultUIA2ServerPackageName
}
if config.UIA2ServerTestPackageName == "" {
config.UIA2ServerTestPackageName = defaultUIA2ServerTestPackageName
}
return config
}
type AndroidDeviceOption func(*AndroidDeviceOptions)
func WithSerialNumber(serial string) AndroidDeviceOption {
return func(device *AndroidDeviceOptions) {
device.SerialNumber = serial
}
}
func WithUIA2(uia2On bool) AndroidDeviceOption {
return func(device *AndroidDeviceOptions) {
device.UIA2 = uia2On
}
}
func WithUIA2IP(ip string) AndroidDeviceOption {
return func(device *AndroidDeviceOptions) {
device.UIA2IP = ip
}
}
func WithUIA2Port(port int) AndroidDeviceOption {
return func(device *AndroidDeviceOptions) {
device.UIA2Port = port
}
}
func WithAdbLogOn(logOn bool) AndroidDeviceOption {
return func(device *AndroidDeviceOptions) {
device.LogOn = logOn
}
}

View File

@@ -1,22 +0,0 @@
package option
func NewBrowserDeviceOptions(opts ...BrowserDeviceOption) *BrowserDeviceOptions {
config := &BrowserDeviceOptions{}
for _, opt := range opts {
opt(config)
}
return config
}
type BrowserDeviceOptions struct {
BrowserID string `json:"browser_id,omitempty" yaml:"browser_id,omitempty"`
LogOn bool `json:"log_on,omitempty" yaml:"log_on,omitempty"`
}
type BrowserDeviceOption func(*BrowserDeviceOptions)
func WithBrowserID(serial string) BrowserDeviceOption {
return func(device *BrowserDeviceOptions) {
device.BrowserID = serial
}
}

View File

@@ -1,96 +0,0 @@
package option
type AlertAction string
const (
AlertActionAccept AlertAction = "accept"
AlertActionDismiss AlertAction = "dismiss"
)
type Capabilities map[string]interface{}
func NewCapabilities() Capabilities {
return make(Capabilities)
}
// WithDefaultAlertAction
func (caps Capabilities) WithDefaultAlertAction(alertAction AlertAction) Capabilities {
caps["defaultAlertAction"] = alertAction
return caps
}
// WithMaxTypingFrequency
//
// Defaults to `60`.
func (caps Capabilities) WithMaxTypingFrequency(n int) Capabilities {
if n <= 0 {
n = 60
}
caps["maxTypingFrequency"] = n
return caps
}
// WithWaitForIdleTimeout
//
// Defaults to `10`
func (caps Capabilities) WithWaitForIdleTimeout(second float64) Capabilities {
caps["waitForIdleTimeout"] = second
return caps
}
// WithShouldUseTestManagerForVisibilityDetection If set to YES will ask TestManagerDaemon for element visibility
//
// Defaults to `false`
func (caps Capabilities) WithShouldUseTestManagerForVisibilityDetection(b bool) Capabilities {
caps["shouldUseTestManagerForVisibilityDetection"] = b
return caps
}
// WithShouldUseCompactResponses If set to YES will use compact (standards-compliant) & faster responses
//
// Defaults to `true`
func (caps Capabilities) WithShouldUseCompactResponses(b bool) Capabilities {
caps["shouldUseCompactResponses"] = b
return caps
}
// WithElementResponseAttributes If shouldUseCompactResponses == NO,
// is the comma-separated list of fields to return with each element.
//
// Defaults to `type,label`.
func (caps Capabilities) WithElementResponseAttributes(s string) Capabilities {
caps["elementResponseAttributes"] = s
return caps
}
// WithShouldUseSingletonTestManager
//
// Defaults to `true`
func (caps Capabilities) WithShouldUseSingletonTestManager(b bool) Capabilities {
caps["shouldUseSingletonTestManager"] = b
return caps
}
// WithDisableAutomaticScreenshots
//
// Defaults to `true`
func (caps Capabilities) WithDisableAutomaticScreenshots(b bool) Capabilities {
caps["disableAutomaticScreenshots"] = b
return caps
}
// WithShouldTerminateApp
//
// Defaults to `true`
func (caps Capabilities) WithShouldTerminateApp(b bool) Capabilities {
caps["shouldTerminateApp"] = b
return caps
}
// WithEventloopIdleDelaySec
// Delays the invocation of '-[XCUIApplicationProcess setEventLoopHasIdled:]' by the timer interval passed.
// which is skipped on setting it to zero.
func (caps Capabilities) WithEventloopIdleDelaySec(second float64) Capabilities {
caps["eventloopIdleDelaySec"] = second
return caps
}

View File

@@ -1,45 +0,0 @@
package option
import "code.byted.org/iesqa/ghdc"
const (
HdcServerHost = "localhost"
HdcServerPort = ghdc.HdcServerPort // 5037
)
type HarmonyDeviceOptions struct {
ConnectKey string `json:"connect_key,omitempty" yaml:"connect_key,omitempty"`
LogOn bool `json:"log_on,omitempty" yaml:"log_on,omitempty"`
}
func (dev *HarmonyDeviceOptions) Options() (deviceOptions []HarmonyDeviceOption) {
if dev.ConnectKey != "" {
deviceOptions = append(deviceOptions, WithConnectKey(dev.ConnectKey))
}
if dev.LogOn {
deviceOptions = append(deviceOptions, WithLogOn(true))
}
return
}
func NewHarmonyDeviceOptions(opts ...HarmonyDeviceOption) (device *HarmonyDeviceOptions) {
device = &HarmonyDeviceOptions{}
for _, option := range opts {
option(device)
}
return
}
type HarmonyDeviceOption func(*HarmonyDeviceOptions)
func WithConnectKey(connectKey string) HarmonyDeviceOption {
return func(device *HarmonyDeviceOptions) {
device.ConnectKey = connectKey
}
}
func WithLogOn(logOn bool) HarmonyDeviceOption {
return func(device *HarmonyDeviceOptions) {
device.LogOn = logOn
}
}

View File

@@ -1,42 +0,0 @@
package option
type InstallOptions struct {
Reinstall bool
GrantPermission bool
Downgrade bool
RetryTimes int
}
type InstallOption func(o *InstallOptions)
func NewInstallOptions(opts ...InstallOption) *InstallOptions {
installOptions := &InstallOptions{}
for _, option := range opts {
option(installOptions)
}
return installOptions
}
func WithReinstall(reinstall bool) InstallOption {
return func(o *InstallOptions) {
o.Reinstall = reinstall
}
}
func WithGrantPermission(grantPermission bool) InstallOption {
return func(o *InstallOptions) {
o.GrantPermission = grantPermission
}
}
func WithDowngrade(downgrade bool) InstallOption {
return func(o *InstallOptions) {
o.Downgrade = downgrade
}
}
func WithRetryTimes(retryTimes int) InstallOption {
return func(o *InstallOptions) {
o.RetryTimes = retryTimes
}
}

View File

@@ -1,150 +0,0 @@
package option
type IOSDeviceOptions struct {
UDID string `json:"udid,omitempty" yaml:"udid,omitempty"`
WDAPort int `json:"port,omitempty" yaml:"port,omitempty"` // WDA remote port
WDAMjpegPort int `json:"mjpeg_port,omitempty" yaml:"mjpeg_port,omitempty"` // WDA remote MJPEG port
LogOn bool `json:"log_on,omitempty" yaml:"log_on,omitempty"`
LazySetup bool `json:"lazy_setup,omitempty" yaml:"lazy_setup,omitempty"` // lazy setup WDA
// switch to iOS springboard before init WDA session
ResetHomeOnStartup bool `json:"reset_home_on_startup,omitempty" yaml:"reset_home_on_startup,omitempty"`
// config appium settings
SnapshotMaxDepth int `json:"snapshot_max_depth,omitempty" yaml:"snapshot_max_depth,omitempty"`
AcceptAlertButtonSelector string `json:"accept_alert_button_selector,omitempty" yaml:"accept_alert_button_selector,omitempty"`
DismissAlertButtonSelector string `json:"dismiss_alert_button_selector,omitempty" yaml:"dismiss_alert_button_selector,omitempty"`
}
func (dev *IOSDeviceOptions) Options() (deviceOptions []IOSDeviceOption) {
if dev.UDID != "" {
deviceOptions = append(deviceOptions, WithUDID(dev.UDID))
}
if dev.WDAPort != 0 {
deviceOptions = append(deviceOptions, WithWDAPort(dev.WDAPort))
}
if dev.WDAMjpegPort != 0 {
deviceOptions = append(deviceOptions, WithWDAMjpegPort(dev.WDAMjpegPort))
}
if dev.LogOn {
deviceOptions = append(deviceOptions, WithWDALogOn(true))
}
if dev.LazySetup {
deviceOptions = append(deviceOptions, WithLazySetup(true))
}
if dev.ResetHomeOnStartup {
deviceOptions = append(deviceOptions, WithResetHomeOnStartup(true))
}
if dev.SnapshotMaxDepth != 0 {
deviceOptions = append(deviceOptions, WithSnapshotMaxDepth(dev.SnapshotMaxDepth))
}
if dev.AcceptAlertButtonSelector != "" {
deviceOptions = append(deviceOptions, WithAcceptAlertButtonSelector(dev.AcceptAlertButtonSelector))
}
if dev.DismissAlertButtonSelector != "" {
deviceOptions = append(deviceOptions, WithDismissAlertButtonSelector(dev.DismissAlertButtonSelector))
}
return
}
const (
defaultWDAPort = 8100
defaultMjpegPort = 9100
)
const (
// Changes the value of maximum depth for traversing elements source tree.
// It may help to prevent out of memory or timeout errors while getting the elements source tree,
// but it might restrict the depth of source tree.
// A part of elements source tree might be lost if the value was too small. Defaults to 50
defaultSnapshotMaxDepth = 10
// Allows to customize accept/dismiss alert button selector.
// It helps you to handle an arbitrary element as accept button in accept alert command.
// The selector should be a valid class chain expression, where the search root is the alert element itself.
// The default button location algorithm is used if the provided selector is wrong or does not match any element.
// e.g. **/XCUIElementTypeButton[`label CONTAINS[c] accept`]
acceptAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'允许','好','仅在使用应用期间','稍后再说'}`]"
dismissAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'不允许','暂不'}`]"
)
func NewIOSDeviceOptions(opts ...IOSDeviceOption) *IOSDeviceOptions {
config := &IOSDeviceOptions{}
for _, opt := range opts {
opt(config)
}
if config.WDAPort == 0 {
config.WDAPort = defaultWDAPort
}
if config.WDAMjpegPort == 0 {
config.WDAMjpegPort = defaultMjpegPort
}
if config.SnapshotMaxDepth == 0 {
config.SnapshotMaxDepth = defaultSnapshotMaxDepth
}
if config.AcceptAlertButtonSelector == "" {
config.AcceptAlertButtonSelector = acceptAlertButtonSelector
}
if config.DismissAlertButtonSelector == "" {
config.DismissAlertButtonSelector = dismissAlertButtonSelector
}
return config
}
type IOSDeviceOption func(*IOSDeviceOptions)
func WithUDID(udid string) IOSDeviceOption {
return func(device *IOSDeviceOptions) {
device.UDID = udid
}
}
func WithWDAPort(port int) IOSDeviceOption {
return func(device *IOSDeviceOptions) {
device.WDAPort = port
}
}
func WithWDAMjpegPort(port int) IOSDeviceOption {
return func(device *IOSDeviceOptions) {
device.WDAMjpegPort = port
}
}
func WithWDALogOn(logOn bool) IOSDeviceOption {
return func(device *IOSDeviceOptions) {
device.LogOn = logOn
}
}
func WithLazySetup(lazySetup bool) IOSDeviceOption {
return func(device *IOSDeviceOptions) {
device.LazySetup = lazySetup
}
}
func WithResetHomeOnStartup(reset bool) IOSDeviceOption {
return func(device *IOSDeviceOptions) {
device.ResetHomeOnStartup = reset
}
}
func WithSnapshotMaxDepth(depth int) IOSDeviceOption {
return func(device *IOSDeviceOptions) {
device.SnapshotMaxDepth = depth
}
}
func WithAcceptAlertButtonSelector(selector string) IOSDeviceOption {
return func(device *IOSDeviceOptions) {
device.AcceptAlertButtonSelector = selector
}
}
func WithDismissAlertButtonSelector(selector string) IOSDeviceOption {
return func(device *IOSDeviceOptions) {
device.DismissAlertButtonSelector = selector
}
}

View File

@@ -1,209 +0,0 @@
package option
import "github.com/httprunner/httprunner/v5/pkg/uixt/types"
type ScreenOptions struct {
ScreenShotOptions
ScreenFilterOptions
}
type ScreenShotOptions struct {
ScreenShotWithOCR bool `json:"screenshot_with_ocr,omitempty" yaml:"screenshot_with_ocr,omitempty"`
ScreenShotWithUpload bool `json:"screenshot_with_upload,omitempty" yaml:"screenshot_with_upload,omitempty"`
ScreenShotWithLiveType bool `json:"screenshot_with_live_type,omitempty" yaml:"screenshot_with_live_type,omitempty"`
ScreenShotWithLivePopularity bool `json:"screenshot_with_live_popularity,omitempty" yaml:"screenshot_with_live_popularity,omitempty"`
ScreenShotWithUITypes []string `json:"screenshot_with_ui_types,omitempty" yaml:"screenshot_with_ui_types,omitempty"`
ScreenShotWithClosePopups bool `json:"screenshot_with_close_popups,omitempty" yaml:"screenshot_with_close_popups,omitempty"`
ScreenShotWithOCRCluster string `json:"screenshot_with_ocr_cluster,omitempty" yaml:"screenshot_with_ocr_cluster,omitempty"`
ScreenShotFileName string `json:"screenshot_file_name,omitempty" yaml:"screenshot_file_name,omitempty"`
}
func (o *ScreenShotOptions) Options() []ActionOption {
options := make([]ActionOption, 0)
if o == nil {
return options
}
// screenshot options
if o.ScreenShotWithOCR {
options = append(options, WithScreenShotOCR(true))
}
if o.ScreenShotWithUpload {
options = append(options, WithScreenShotUpload(true))
}
if o.ScreenShotWithLiveType {
options = append(options, WithScreenShotLiveType(true))
}
if o.ScreenShotWithLivePopularity {
options = append(options, WithScreenShotLivePopularity(true))
}
if len(o.ScreenShotWithUITypes) > 0 {
options = append(options, WithScreenShotUITypes(o.ScreenShotWithUITypes...))
}
if o.ScreenShotWithClosePopups {
options = append(options, WithScreenShotClosePopups(true))
}
if o.ScreenShotWithOCRCluster != "" {
options = append(options, WithScreenOCRCluster(o.ScreenShotWithOCRCluster))
}
if o.ScreenShotFileName != "" {
options = append(options, WithScreenShotFileName(o.ScreenShotFileName))
}
return options
}
func (o *ScreenShotOptions) List() []string {
options := []string{}
if o.ScreenShotWithUpload {
options = append(options, "upload")
}
if o.ScreenShotWithOCR {
options = append(options, "ocr")
}
if o.ScreenShotWithLiveType {
options = append(options, "liveType")
}
if o.ScreenShotWithLivePopularity {
options = append(options, "livePopularity")
}
// UI detection
if len(o.ScreenShotWithUITypes) > 0 {
options = append(options, "ui")
}
if o.ScreenShotWithClosePopups {
options = append(options, "close")
}
return options
}
func WithScreenShotOCR(ocrOn bool) ActionOption {
return func(o *ActionOptions) {
o.ScreenShotWithOCR = ocrOn
}
}
func WithScreenShotUpload(uploadOn bool) ActionOption {
return func(o *ActionOptions) {
o.ScreenShotWithUpload = uploadOn
}
}
func WithScreenShotLiveType(liveTypeOn bool) ActionOption {
return func(o *ActionOptions) {
o.ScreenShotWithLiveType = liveTypeOn
}
}
func WithScreenShotLivePopularity(livePopularityOn bool) ActionOption {
return func(o *ActionOptions) {
o.ScreenShotWithLivePopularity = livePopularityOn
}
}
func WithScreenShotUITypes(uiTypes ...string) ActionOption {
return func(o *ActionOptions) {
o.ScreenShotWithUITypes = uiTypes
}
}
func WithScreenShotClosePopups(closeOn bool) ActionOption {
return func(o *ActionOptions) {
o.ScreenShotWithClosePopups = closeOn
}
}
func WithScreenOCRCluster(ocrCluster string) ActionOption {
return func(o *ActionOptions) {
o.ScreenShotWithOCRCluster = ocrCluster
}
}
func WithScreenShotFileName(fileName string) ActionOption {
return func(o *ActionOptions) {
o.ScreenShotFileName = fileName
}
}
// (x1, y1) is the top left corner, (x2, y2) is the bottom right corner
// [x1, y1, x2, y2] in percentage of the screen
type Scope []float64
func (s Scope) ToAbs(windowSize types.Size) AbsScope {
x1, y1, x2, y2 := s[0], s[1], s[2], s[3]
// convert relative scope to absolute scope
absX1 := int(x1 * float64(windowSize.Width))
absY1 := int(y1 * float64(windowSize.Height))
absX2 := int(x2 * float64(windowSize.Width))
absY2 := int(y2 * float64(windowSize.Height))
return AbsScope{absX1, absY1, absX2, absY2}
}
// [x1, y1, x2, y2] in absolute pixels
type AbsScope []int
func (s AbsScope) Option() ActionOption {
return WithAbsScope(s[0], s[1], s[2], s[3])
}
func NewScreenFilterOptions(opts ...ActionOption) *ActionOptions {
options := &ActionOptions{}
for _, option := range opts {
option(options)
}
return options
}
type ScreenFilterOptions struct {
// scope related
Scope Scope `json:"scope,omitempty" yaml:"scope,omitempty"`
AbsScope AbsScope `json:"abs_scope,omitempty" yaml:"abs_scope,omitempty"`
Regex bool `json:"regex,omitempty" yaml:"regex,omitempty"` // use regex to match text
Offset []int `json:"offset,omitempty" yaml:"offset,omitempty"` // used to tap offset of point
OffsetRandomRange []int `json:"offset_random_range,omitempty" yaml:"offset_random_range,omitempty"` // set random range [min, max] for tap/swipe points
Index int `json:"index,omitempty" yaml:"index,omitempty"` // index of the target element
MatchOne bool `json:"match_one,omitempty" yaml:"match_one,omitempty"`
IgnoreNotFoundError bool `json:"ignore_NotFoundError,omitempty" yaml:"ignore_NotFoundError,omitempty"` // ignore error if target element not found // match one of the targets if existed
}
// WithScope inputs area of [(x1,y1), (x2,y2)]
// x1, y1, x2, y2 are all in [0, 1], which means the relative position of the screen
func WithScope(x1, y1, x2, y2 float64) ActionOption {
return func(o *ActionOptions) {
o.Scope = Scope{x1, y1, x2, y2}
}
}
// WithAbsScope inputs area of [(x1,y1), (x2,y2)]
// x1, y1, x2, y2 are all absolute position of the screen
func WithAbsScope(x1, y1, x2, y2 int) ActionOption {
return func(o *ActionOptions) {
o.AbsScope = AbsScope{x1, y1, x2, y2}
}
}
// tap [x, y] with offset [offsetX, offsetY]
func WithTapOffset(offsetX, offsetY int) ActionOption {
return func(o *ActionOptions) {
o.Offset = []int{offsetX, offsetY}
}
}
func WithRegex(regex bool) ActionOption {
return func(o *ActionOptions) {
o.Regex = regex
}
}
func WithMatchOne(matchOne bool) ActionOption {
return func(o *ActionOptions) {
o.MatchOne = matchOne
}
}
func WithIndex(index int) ActionOption {
return func(o *ActionOptions) {
o.Index = index
}
}

View File

@@ -1,75 +0,0 @@
package option
import "strings"
func NewSourceOptions(opts ...SourceOption) *SourceOptions {
options := &SourceOptions{}
for _, option := range opts {
option(options)
}
return options
}
type SourceOptions struct {
Format SourceFormat `json:"format,omitempty"`
ProcessName string `json:"processName,omitempty"`
Scope string `json:"scope,omitempty"`
ExcludedAttributes string `json:"excluded_attributes,omitempty"`
}
func (o *SourceOptions) Query() string {
query := []string{}
if o.Format != "" {
query = append(query, "format="+string(o.Format))
}
if o.ProcessName != "" {
query = append(query, "processName="+o.ProcessName)
}
if o.Scope != "" {
query = append(query, "scope="+o.Scope)
}
if o.ExcludedAttributes != "" {
query = append(query, "excluded_attributes="+o.ExcludedAttributes)
}
return strings.Join(query, "&")
}
type SourceOption func(o *SourceOptions)
type SourceFormat string
const (
SourceFormatJSON SourceFormat = "json"
SourceFormatXML SourceFormat = "xml"
SourceFormatDescription SourceFormat = "description"
)
// WithFormat specify Application elements tree format
// `json` or `xml` or `description`
func WithFormat(format SourceFormat) SourceOption {
return func(o *SourceOptions) {
o.Format = format
}
}
func WithProcessName(name string) SourceOption {
return func(o *SourceOptions) {
o.ProcessName = name
}
}
// WithSourceScope Allows to provide XML scope.
// only `xml` is supported.
func WithSourceScope(scope string) SourceOption {
return func(o *SourceOptions) {
o.Scope = scope
}
}
// WithExcludedAttributes Excludes the given attribute names.
// only `xml` is supported.
func WithExcludedAttributes(attributes []string) SourceOption {
return func(o *SourceOptions) {
o.ExcludedAttributes = strings.Join(attributes, ",")
}
}

View File

@@ -1,60 +0,0 @@
package types
type AppInfo struct {
Name string `json:"name,omitempty"`
AppBaseInfo
}
type WindowInfo struct {
PackageName string `json:"packageName,omitempty"`
Activity string `json:"activity,omitempty"`
}
type AppBaseInfo struct {
Pid int `json:"pid,omitempty"`
BundleId string `json:"bundleId,omitempty"` // ios package name
ViewController string `json:"viewController,omitempty"` // ios view controller
PackageName string `json:"packageName,omitempty"` // android package name
Activity string `json:"activity,omitempty"` // android activity
VersionName string `json:"versionName,omitempty"`
VersionCode interface{} `json:"versionCode,omitempty"` // int or string
AppName string `json:"appName,omitempty"`
AppPath string `json:"appPath,omitempty"`
AppMD5 string `json:"appMD5,omitempty"`
// AppIcon string `json:"appIcon,omitempty"`
}
type AppState int
const (
AppStateNotRunning AppState = 1 << iota
AppStateRunningBack
AppStateRunningFront
)
func (v AppState) String() string {
switch v {
case AppStateNotRunning:
return "Not Running"
case AppStateRunningBack:
return "Running (Back)"
case AppStateRunningFront:
return "Running (Front)"
default:
return "UNKNOWN"
}
}
// PasteboardType The type of the item on the pasteboard.
type PasteboardType string
const (
PasteboardTypePlaintext PasteboardType = "plaintext"
PasteboardTypeImage PasteboardType = "image"
PasteboardTypeUrl PasteboardType = "url"
)
const (
TextBackspace string = "\u0008"
TextDelete string = "\u007F"
)

View File

@@ -1,222 +0,0 @@
package types
import "fmt"
// DeviceStatus example:
//
// {
// "status": 0,
// "sessionId": "7DD3B0F7-958B-45F1-B99D-745B4EEFE178",
// "value": {
// "message": "WebDriverAgent is ready to accept commands",
// "state": "success",
// "os": {
// "testmanagerdVersion": 28,
// "name": "iOS",
// "sdkVersion": "16.0",
// "version": "15.3.1"
// },
// "ios": {
// "ip": "169.254.237.64"
// },
// "ready": true,
// "build": {
// "time": "Jun 21 2024 11:11:37",
// "productBundleIdentifier": "com.facebook.WebDriverAgentRunner"
// }
// }
// }
type DeviceStatus struct {
Message string `json:"message"`
State string `json:"state"`
OS struct {
TestmanagerdVersion int `json:"testmanagerdVersion"`
Name string `json:"name"`
SdkVersion string `json:"sdkVersion"`
Version string `json:"version"`
} `json:"os"`
IOS struct {
IP string `json:"ip"`
SimulatorVersion string `json:"simulatorVersion"`
} `json:"ios"`
Ready bool `json:"ready"`
Build struct {
Time string `json:"time"`
ProductBundleIdentifier string `json:"productBundleIdentifier"`
} `json:"build"`
}
type DeviceInfo struct {
TimeZone string `json:"timeZone"`
CurrentLocale string `json:"currentLocale"`
Model string `json:"model"`
UUID string `json:"uuid"`
UserInterfaceIdiom int `json:"userInterfaceIdiom"`
UserInterfaceStyle string `json:"userInterfaceStyle"`
Name string `json:"name"`
IsSimulator bool `json:"isSimulator"`
ThermalState int `json:"thermalState"`
// ANDROID_ID A 64-bit number (as a hex string) that is uniquely generated when the user
// first sets up the device and should remain constant for the lifetime of the user's device. The value
// may change if a factory reset is performed on the device.
AndroidID string `json:"androidId"`
// Build.MANUFACTURER value
Manufacturer string `json:"manufacturer"`
// Build.BRAND value
Brand string `json:"brand"`
// Current running OS's API VERSION
APIVersion string `json:"apiVersion"`
// The current version string, for example "1.0" or "3.4b5"
PlatformVersion string `json:"platformVersion"`
// the name of the current celluar network carrier
CarrierName string `json:"carrierName"`
// the real size of the default display
RealDisplaySize string `json:"realDisplaySize"`
// The logical density of the display in Density Independent Pixel units.
DisplayDensity int `json:"displayDensity"`
// available networks
Networks []networkInfo `json:"networks"`
// current system locale
Locale string `json:"locale"`
Bluetooth struct {
State string `json:"state"`
} `json:"bluetooth"`
}
type networkCapabilities struct {
TransportTypes string `json:"transportTypes"`
NetworkCapabilities string `json:"networkCapabilities"`
LinkUpstreamBandwidthKbps int `json:"linkUpstreamBandwidthKbps"`
LinkDownBandwidthKbps int `json:"linkDownBandwidthKbps"`
SignalStrength int `json:"signalStrength"`
SSID string `json:"SSID"`
}
type networkInfo struct {
Type int `json:"type"`
TypeName string `json:"typeName"`
Subtype int `json:"subtype"`
SubtypeName string `json:"subtypeName"`
IsConnected bool `json:"isConnected"`
DetailedState string `json:"detailedState"`
State string `json:"state"`
ExtraInfo string `json:"extraInfo"`
IsAvailable bool `json:"isAvailable"`
IsRoaming bool `json:"isRoaming"`
IsFailover bool `json:"isFailover"`
Capabilities networkCapabilities `json:"capabilities"`
}
type Location struct {
AuthorizationStatus int `json:"authorizationStatus"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
Altitude float64 `json:"altitude"`
}
type BatteryInfo struct {
// Battery level in range [0.0, 1.0], where 1.0 means 100% charge.
Level float64 `json:"level"`
// Battery state ( 1: on battery, discharging; 2: plugged in, less than 100%, 3: plugged in, at 100% )
State BatteryState `json:"state"`
Status BatteryStatus `json:"status"`
}
type BatteryState int
const (
_ = iota
BatteryStateUnplugged BatteryState = iota // on battery, discharging
BatteryStateCharging // plugged in, less than 100%
BatteryStateFull // plugged in, at 100%
)
func (v BatteryState) String() string {
switch v {
case BatteryStateUnplugged:
return "On battery, discharging"
case BatteryStateCharging:
return "Plugged in, less than 100%"
case BatteryStateFull:
return "Plugged in, at 100%"
default:
return "UNKNOWN"
}
}
type BatteryStatus int
const (
_ = iota
BatteryStatusUnknown BatteryStatus = iota
BatteryStatusCharging
BatteryStatusDischarging
BatteryStatusNotCharging
BatteryStatusFull
)
func (bs BatteryStatus) String() string {
switch bs {
case BatteryStatusUnknown:
return "unknown"
case BatteryStatusCharging:
return "charging"
case BatteryStatusDischarging:
return "discharging"
case BatteryStatusNotCharging:
return "not charging"
case BatteryStatusFull:
return "full"
default:
return fmt.Sprintf("unknown status code (%d)", bs)
}
}
// DeviceButton A physical button on an iOS device.
type DeviceButton string
const (
DeviceButtonHome DeviceButton = "home"
DeviceButtonVolumeUp DeviceButton = "volumeUp"
DeviceButtonVolumeDown DeviceButton = "volumeDown"
)
type NotificationType string
const (
NotificationTypePlain NotificationType = "plain"
NotificationTypeDarwin NotificationType = "darwin"
)
type Orientation string
const (
// OrientationPortrait Device oriented vertically, home button on the bottom
OrientationPortrait Orientation = "PORTRAIT"
// OrientationPortraitUpsideDown Device oriented vertically, home button on the top
OrientationPortraitUpsideDown Orientation = "UIA_DEVICE_ORIENTATION_PORTRAIT_UPSIDEDOWN"
// OrientationLandscapeLeft Device oriented horizontally, home button on the right
OrientationLandscapeLeft Orientation = "LANDSCAPE"
// OrientationLandscapeRight Device oriented horizontally, home button on the left
OrientationLandscapeRight Orientation = "UIA_DEVICE_ORIENTATION_LANDSCAPERIGHT"
)
type Rotation struct {
X int `json:"x"`
Y int `json:"y"`
Z int `json:"z"`
}
type Direction string
const (
DirectionUp Direction = "up"
DirectionDown Direction = "down"
DirectionLeft Direction = "left"
DirectionRight Direction = "right"
)

View File

@@ -1,5 +0,0 @@
package types
import "errors"
var ErrDriverNotImplemented = errors.New("driver method not implemented")

View File

@@ -1,10 +0,0 @@
package types
type Size struct {
Width int `json:"width"`
Height int `json:"height"`
}
func (s Size) IsNil() bool {
return s.Width == 0 && s.Height == 0
}