Merge pull request #2 from httprunner/master

合并4.3.3代码
This commit is contained in:
yangfan
2023-04-20 00:16:14 +08:00
committed by GitHub
70 changed files with 1726 additions and 415 deletions

View File

@@ -118,6 +118,12 @@ Use "hrp [command] --help" for more information about a command.
<a href="https://httprunner.com/docs/cases/umcare"><img src="https://httprunner.com/image/logo/umcare.png" title="通用环球医疗 - 使用 HttpRunner 实践接口自动化测试" width="100"></a>
<a href="https://httprunner.com/docs/cases/mihoyo"><img src="https://httprunner.com/image/logo/miHoYo.png" title="米哈游 - 基于 HttpRunner 搭建接口自动化测试体系" width="100"></a>
## Sponsor
[<img src="https://testing-studio.com/img/icon.png" alt="霍格沃兹测试开发学社" width="500">](https://qrcode.testing-studio.com/f?from=HttpRunner&url=https://testing-studio.com/)
> 霍格沃兹测试开发学社是中国软件测试开发高端教育品牌,产品由国内顶尖软件测试开发技术专家携手打造,为企业与个人提供专业的技能培训与咨询、测试工具与测试平台、测试外包与测试众包服务。领域涵盖 App/Web 自动化测试、接口自动化测试、性能测试、安全测试、持续交付/DevOps、测试左移、测试右移、精准测试、测试平台开发、测试管理等方向。-> [**联系我们**](http://qrcode.testing-studio.com/f?from=HttpRunner&url=https://ceshiren.com/t/topic/23745)
## Subscribe
关注 HttpRunner 的微信公众号,第一时间获得最新资讯。

View File

@@ -112,6 +112,12 @@ Use "hrp [command] --help" for more information about a command.
<a href="https://httprunner.com/docs/cases/mihoyo"><img src="https://httprunner.com/image/logo/miHoYo.png" title="米哈游 - 基于 HttpRunner 搭建接口自动化测试体系" width="100"></a>
## 赞助商
[<img src="https://testing-studio.com/img/icon.png" alt="霍格沃兹测试开发学社" width="500">](https://qrcode.testing-studio.com/f?from=HttpRunner&url=https://testing-studio.com/)
> 霍格沃兹测试开发学社是中国软件测试开发高端教育品牌,产品由国内顶尖软件测试开发技术专家携手打造,为企业与个人提供专业的技能培训与咨询、测试工具与测试平台、测试外包与测试众包服务。领域涵盖 App/Web 自动化测试、接口自动化测试、性能测试、安全测试、持续交付/DevOps、测试左移、测试右移、精准测试、测试平台开发、测试管理等方向。-> [**联系我们**](http://qrcode.testing-studio.com/f?from=HttpRunner&url=https://ceshiren.com/t/topic/23745)
## Subscribe
关注 HttpRunner 的微信公众号,第一时间获得最新资讯。

View File

@@ -1,5 +1,26 @@
# Release History
## v4.3.3 (2023-04-19)
**go version**
- feat: add `sleep_random` to sleep random seconds, with weight for multiple time ranges
- feat: input text with adb
- feat: add adb `screencap` sub command
- feat: add `IsAppInForeground` to check if the given package is in foreground
- feat: check if app is in foreground when step failed
- feat: add validator AssertAppInForeground and AssertAppNotInForeground
- feat: save screenshots of all steps including ocr and cv recognition process data
- fix: adb driver for TapFloat
- fix: stop logcat only when enabled
- fix: do not fail case when kill logcat error
- fix: take screenshot after each step
- fix: screencap compatibility for shell v1 and v2 protocol
- fix: display parsed url in html report
- fix: fast fail not closing the websocket connection
- fix #1467: failed to parse parameters with plugin functions
- fix #1549: avoid duplicate creating plugins
## v4.3.2 (2022-12-26)
**go version**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

BIN
docs/assets/hogwarts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v4.1.4
# NOTE: Generated By HttpRunner v4.3.0
# FROM: a-b.c/1.yml
from httprunner import HttpRunner, Config, Step, RunRequest

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v4.1.4
# NOTE: Generated By HttpRunner v4.3.0
# FROM: a-b.c/2 3.yml
from httprunner import HttpRunner, Config, Step, RunRequest
from httprunner import RunTestCase

View File

@@ -1,6 +1,6 @@
config:
name: basic test with httpbin
base_url: http://httpbin.org/
base_url: https://httpbin.org/
teststeps:
-

View File

@@ -5,7 +5,7 @@ from httprunner import HttpRunner, Config, Step, RunRequest
class TestCaseValidate(HttpRunner):
config = Config("basic test with httpbin").base_url("http://httpbin.org/")
config = Config("basic test with httpbin").base_url("https://httpbin.org/")
teststeps = [
Step(

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v4.1.4
# NOTE: Generated By HttpRunner v4.3.0
# FROM: request_methods/hardcode.yml
from httprunner import HttpRunner, Config, Step, RunRequest

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v4.1.4
# NOTE: Generated By HttpRunner v4.3.0
# FROM: request_methods/request_with_functions.yml
from httprunner import HttpRunner, Config, Step, RunRequest

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v4.1.4
# NOTE: Generated By HttpRunner v4.3.0
# FROM: request_methods/request_with_parameters.yml
import pytest

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v4.1.4
# NOTE: Generated By HttpRunner v4.3.0
# FROM: request_methods/request_with_testcase_reference.yml
from httprunner import HttpRunner, Config, Step, RunRequest
from httprunner import RunTestCase

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v4.1.4
# NOTE: Generated By HttpRunner v4.3.0
# FROM: request_methods/request_with_variables.yml
from httprunner import HttpRunner, Config, Step, RunRequest

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v4.1.4
# NOTE: Generated By HttpRunner v4.3.0
# FROM: request_methods/validate_with_functions.yml
from httprunner import HttpRunner, Config, Step, RunRequest

View File

@@ -1,4 +1,4 @@
# NOTE: Generated By HttpRunner v4.1.4
# NOTE: Generated By HttpRunner v4.3.0
# FROM: request_methods/validate_with_variables.yml
from httprunner import HttpRunner, Config, Step, RunRequest

View File

@@ -1,44 +0,0 @@
//go:build localtest
package uitest
import (
"testing"
"github.com/httprunner/httprunner/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
)
func TestAndroidDouYinLive(t *testing.T) {
testCase := &hrp.TestCase{
Config: hrp.NewConfig("通过 feed 头像进入抖音直播间").
SetAndroid(uixt.WithUIA2(false), uixt.WithAdbLogOn(true)),
TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音").
Android().
Home().
AppTerminate("com.ss.android.ugc.aweme"). // 关闭已运行的抖音,确保启动抖音后在「抖音」首页
SwipeToTapApp("抖音", uixt.WithMaxRetryTimes(5)).
Sleep(10),
hrp.NewStep("处理青少年弹窗").
Android().
Tap("推荐").
TapByOCR("我知道了", uixt.WithIgnoreNotFoundError(true)).
Validate().
AssertOCRExists("首页", "抖音启动失败,「首页」不存在"),
hrp.NewStep("在推荐页上划,直到出现 feed 头像「直播」").
Android().
SwipeToTapText("直播", uixt.WithMaxRetryTimes(10), uixt.WithIdentifier("进入直播间")),
hrp.NewStep("向上滑动,等待 10s").
Android().
SwipeUp(uixt.WithIdentifier("第一次上划")).Sleep(10).ScreenShot(). // 上划 1 次,等待 10s截图保存
SwipeUp(uixt.WithIdentifier("第二次上划")).Sleep(10).ScreenShot(), // 再上划 1 次,等待 10s截图保存
},
}
runner := hrp.NewRunner(t).SetSaveTests(true)
err := runner.Run(testCase)
if err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,134 @@
{
"config": {
"name": "点播_抖音_滑动场景_随机间隔_android",
"variables": {
"device": "${ENV(SerialNumber)}"
},
"android": [
{
"serial": "$device"
}
]
},
"teststeps": [
{
"name": "启动抖音",
"android": {
"actions": [
{
"method": "app_terminate",
"params": "com.ss.android.ugc.aweme"
},
{
"method": "app_launch",
"params": "com.ss.android.ugc.aweme"
},
{
"method": "sleep",
"params": 10
}
]
},
"validate": [
{
"check": "ui_foreground_app",
"assert": "equal",
"expect": "com.ss.android.ugc.aweme",
"msg": "app [com.ss.android.ugc.aweme] should be in foreground"
}
]
},
{
"name": "处理青少年弹窗",
"android": {
"actions": [
{
"method": "tap_ocr",
"params": "我知道了",
"ignore_NotFoundError": true
}
]
}
},
{
"name": "滑动 Feed 3 次,随机间隔 0-5s",
"android": {
"actions": [
{
"method": "swipe",
"params": "up"
},
{
"method": "sleep_random",
"params": [
0,
5
]
}
]
},
"loops": 3
},
{
"name": "滑动 Feed 1 次,随机间隔 5-10s",
"android": {
"actions": [
{
"method": "swipe",
"params": "up"
},
{
"method": "sleep_random",
"params": [
5,
10
]
}
]
},
"loops": 1
},
{
"name": "滑动 Feed 10 次70% 随机间隔 0-5s30% 随机间隔 5-10s",
"android": {
"actions": [
{
"method": "swipe",
"params": "up"
},
{
"method": "sleep_random",
"params": [
0,
5,
0.7,
5,
10,
0.3
]
}
]
},
"loops": 10
},
{
"name": "exit",
"android": {
"actions": [
{
"method": "app_terminate",
"params": "com.ss.android.ugc.aweme"
}
]
},
"validate": [
{
"check": "ui_foreground_app",
"assert": "not_equal",
"expect": "com.ss.android.ugc.aweme",
"msg": "app [com.ss.android.ugc.aweme] should not be in foreground"
}
]
}
]
}

View File

@@ -0,0 +1,62 @@
//go:build localtest
package uitest
import (
"testing"
"github.com/httprunner/httprunner/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
)
func TestAndroidDouyinFeedTest(t *testing.T) {
testCase := &hrp.TestCase{
Config: hrp.NewConfig("点播_抖音_滑动场景_随机间隔_android").
WithVariables(map[string]interface{}{
"device": "${ENV(SerialNumber)}",
}).
SetAndroid(uixt.WithSerialNumber("$device")),
TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音").
Android().
AppTerminate("com.ss.android.ugc.aweme").
AppLaunch("com.ss.android.ugc.aweme").
Sleep(10).
Validate().
AssertAppInForeground("com.ss.android.ugc.aweme"),
hrp.NewStep("处理青少年弹窗").
Android().
TapByOCR("我知道了", uixt.WithIgnoreNotFoundError(true)),
hrp.NewStep("滑动 Feed 3 次,随机间隔 0-5s").
Loop(3).
Android().
SwipeUp().
SleepRandom(0, 5),
hrp.NewStep("滑动 Feed 1 次,随机间隔 5-10s").
Loop(1).
Android().
SwipeUp().
SleepRandom(5, 10),
hrp.NewStep("滑动 Feed 10 次70% 随机间隔 0-5s30% 随机间隔 5-10s").
Loop(10).
Android().
SwipeUp().
SleepRandom(0, 5, 0.7, 5, 10, 0.3),
hrp.NewStep("exit").
Android().
AppTerminate("com.ss.android.ugc.aweme").
Validate().
AssertAppNotInForeground("com.ss.android.ugc.aweme"),
},
}
if err := testCase.Dump2JSON("demo_android_feed_swipe.json"); err != nil {
t.Fatal(err)
}
runner := hrp.NewRunner(t).SetSaveTests(true)
err := runner.Run(testCase)
if err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,140 @@
{
"config": {
"name": "点播_抖音_滑动场景_随机间隔_android",
"variables": {
"device": "${ENV(SerialNumber)}"
},
"android": [
{
"serial": "$device"
}
]
},
"teststeps": [
{
"name": "启动抖音",
"android": {
"actions": [
{
"method": "app_terminate",
"params": "com.ss.android.ugc.aweme"
},
{
"method": "app_launch",
"params": "com.ss.android.ugc.aweme"
},
{
"method": "sleep",
"params": 5
}
]
},
"validate": [
{
"check": "ui_foreground_app",
"assert": "equal",
"expect": "com.ss.android.ugc.aweme",
"msg": "app [com.ss.android.ugc.aweme] should be in foreground"
}
]
},
{
"name": "处理青少年弹窗",
"android": {
"actions": [
{
"method": "tap_ocr",
"params": "我知道了",
"ignore_NotFoundError": true
}
]
}
},
{
"name": "在推荐页上划,直到出现「点击进入直播间」",
"android": {
"actions": [
{
"method": "swipe_to_tap_text",
"params": "点击进入直播间",
"identifier": "进入直播间",
"max_retry_times": 10
}
]
}
},
{
"name": "滑动 Feed 5 次60% 随机间隔 0-5s40% 随机间隔 5-10s",
"android": {
"actions": [
{
"method": "swipe",
"params": "up"
},
{
"method": "sleep_random",
"params": [
0,
5,
0.6,
5,
10,
0.4
]
}
]
},
"loops": 5
},
{
"name": "向上滑动,等待 10s",
"android": {
"actions": [
{
"method": "swipe",
"params": "up",
"identifier": "第一次上划"
},
{
"method": "sleep",
"params": 10
},
{
"method": "screenshot"
},
{
"method": "swipe",
"params": "up",
"identifier": "第二次上划"
},
{
"method": "sleep",
"params": 10
},
{
"method": "screenshot"
}
]
}
},
{
"name": "exit",
"android": {
"actions": [
{
"method": "app_terminate",
"params": "com.ss.android.ugc.aweme"
}
]
},
"validate": [
{
"check": "ui_foreground_app",
"assert": "not_equal",
"expect": "com.ss.android.ugc.aweme",
"msg": "app [com.ss.android.ugc.aweme] should not be in foreground"
}
]
}
]
}

View File

@@ -0,0 +1,59 @@
//go:build localtest
package uitest
import (
"testing"
"github.com/httprunner/httprunner/v4/hrp"
"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
)
func TestAndroidLiveSwipeTest(t *testing.T) {
testCase := &hrp.TestCase{
Config: hrp.NewConfig("点播_抖音_滑动场景_随机间隔_android").
WithVariables(map[string]interface{}{
"device": "${ENV(SerialNumber)}",
}).
SetAndroid(uixt.WithSerialNumber("$device")),
TestSteps: []hrp.IStep{
hrp.NewStep("启动抖音").
Android().
AppTerminate("com.ss.android.ugc.aweme").
AppLaunch("com.ss.android.ugc.aweme").
Sleep(5).
Validate().
AssertAppInForeground("com.ss.android.ugc.aweme"),
hrp.NewStep("处理青少年弹窗").
Android().
TapByOCR("我知道了", uixt.WithIgnoreNotFoundError(true)),
hrp.NewStep("在推荐页上划,直到出现「点击进入直播间」").
Android().
SwipeToTapText("点击进入直播间", uixt.WithMaxRetryTimes(10), uixt.WithIdentifier("进入直播间")),
hrp.NewStep("滑动 Feed 5 次60% 随机间隔 0-5s40% 随机间隔 5-10s").
Loop(5).
Android().
SwipeUp().
SleepRandom(0, 5, 0.6, 5, 10, 0.4),
hrp.NewStep("向上滑动,等待 10s").
Android().
SwipeUp(uixt.WithIdentifier("第一次上划")).Sleep(10).ScreenShot(). // 上划 1 次,等待 10s截图保存
SwipeUp(uixt.WithIdentifier("第二次上划")).Sleep(10).ScreenShot(), // 再上划 1 次,等待 10s截图保存
hrp.NewStep("exit").
Android().
AppTerminate("com.ss.android.ugc.aweme").
Validate().
AssertAppNotInForeground("com.ss.android.ugc.aweme"),
},
}
if err := testCase.Dump2JSON("demo_android_live_swipe.json"); err != nil {
t.Fatal(err)
}
runner := hrp.NewRunner(t).SetSaveTests(true)
err := runner.Run(testCase)
if err != nil {
t.Fatal(err)
}
}

View File

@@ -45,10 +45,7 @@ func TestIOSDouyinLive(t *testing.T) {
},
}
if err := testCase.Dump2JSON("demo_douyin_live.json"); err != nil {
t.Fatal(err)
}
if err := testCase.Dump2YAML("demo_douyin_live.yaml"); err != nil {
if err := testCase.Dump2JSON("demo_ios_live_swipe.json"); err != nil {
t.Fatal(err)
}

View File

@@ -150,11 +150,7 @@ func NewWorldCupLive(device uixt.Device, matchName, bundleID string, duration, i
func (wc *WorldCupLive) getCurrentLiveTime(utcTime time.Time) error {
utcTimeStr := utcTime.Format("15:04:05")
fileName := filepath.Join(
wc.resultDir, "screenshot", utcTimeStr)
ocrTexts, err := wc.driver.GetTextsByOCR(
uixt.WithScreenShot(fileName),
)
ocrTexts, err := wc.driver.GetTextsByOCR()
if err != nil {
log.Error().Err(err).Msg("get ocr texts failed")
return err

15
go.mod
View File

@@ -22,13 +22,13 @@ require (
github.com/olekukonko/tablewriter v0.0.5
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.13.0
github.com/rs/zerolog v1.28.0
github.com/rs/zerolog v1.29.1
github.com/satori/go.uuid v1.2.0
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/spf13/cobra v1.5.0
github.com/stretchr/testify v1.8.0
gocv.io/x/gocv v0.31.0
golang.org/x/net v0.0.0-20220919232410-f2f64ebce3c1
golang.org/x/net v0.7.0
golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1
google.golang.org/grpc v1.49.0
google.golang.org/protobuf v1.28.1
@@ -56,7 +56,7 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
@@ -73,12 +73,11 @@ require (
github.com/tklauser/go-sysconf v0.3.10 // indirect
github.com/tklauser/numcpus v0.5.0 // indirect
github.com/yusufpapurcu/wmi v1.2.2 // indirect
golang.org/x/mod v0.4.2 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/sync v0.0.0-20220907140024-f12130a52804 // indirect
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.7 // indirect
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/tools v0.1.12 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

28
go.sum
View File

@@ -89,7 +89,7 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -301,8 +301,9 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -370,8 +371,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
@@ -463,8 +464,9 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -512,8 +514,8 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220919232410-f2f64ebce3c1 h1:TWZxd/th7FbRSMret2MVQdlI8uT49QEtwZdvJrxjEHU=
golang.org/x/net v0.0.0-20220919232410-f2f64ebce3c1/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -625,8 +627,9 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc=
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -637,8 +640,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -694,15 +698,15 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0=
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=

View File

@@ -361,6 +361,10 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend
}
mutex.Unlock()
defer func() {
sessionRunner.releaseResources()
}()
startTime := time.Now()
for _, step := range testcase.TestSteps {
// TODO: parse step struct

View File

@@ -10,7 +10,7 @@ func TestBoomerStandaloneRun(t *testing.T) {
defer removeHashicorpGoPlugin()
testcase1 := &TestCase{
Config: NewConfig("TestCase1").SetBaseURL("http://httpbin.org"),
Config: NewConfig("TestCase1").SetBaseURL("https://httpbin.org"),
TestSteps: []IStep{
NewStep("headers").
GET("/headers").

View File

@@ -5,10 +5,8 @@ import (
"fmt"
"os"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v4/hrp/pkg/gadb"
"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
)
@@ -21,22 +19,10 @@ var listAndroidDevicesCmd = &cobra.Command{
Use: "devices",
Short: "List all Android devices",
RunE: func(cmd *cobra.Command, args []string) error {
devices, err := uixt.DeviceList()
deviceList, err := uixt.GetAndroidDevices(serial)
if err != nil {
return errors.Wrap(err, "list android devices failed")
}
var deviceList []gadb.Device
// filter by serial
for _, d := range devices {
if serial != "" && serial != d.Serial() {
continue
}
deviceList = append(deviceList, d)
}
if serial != "" && len(deviceList) == 0 {
fmt.Printf("no android device found for serial: %s\n", serial)
os.Exit(1)
fmt.Println(err)
os.Exit(0)
}
for _, d := range deviceList {

View File

@@ -1,6 +1,13 @@
package adb
import "github.com/spf13/cobra"
import (
"fmt"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v4/hrp/pkg/gadb"
"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
)
var androidRootCmd = &cobra.Command{
Use: "adb",
@@ -8,6 +15,17 @@ var androidRootCmd = &cobra.Command{
PersistentPreRun: func(cmd *cobra.Command, args []string) {},
}
func getDevice(serial string) (*gadb.Device, error) {
devices, err := uixt.GetAndroidDevices(serial)
if err != nil {
return nil, err
}
if len(devices) > 1 {
return nil, fmt.Errorf("found multiple attached devices, please specify android serial")
}
return devices[0], nil
}
func Init(rootCmd *cobra.Command) {
rootCmd.AddCommand(androidRootCmd)
}

38
hrp/cmd/adb/screencap.go Normal file
View File

@@ -0,0 +1,38 @@
package adb
import (
"fmt"
"io/ioutil"
"github.com/spf13/cobra"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
)
var screencapAndroidDevicesCmd = &cobra.Command{
Use: "screencap",
Short: "Start android screen capture",
RunE: func(cmd *cobra.Command, args []string) error {
device, err := getDevice(serial)
if err != nil {
return err
}
res, err := device.ScreenCap()
if err != nil {
return err
}
filepath := fmt.Sprintf("%s.png", builtin.GenNameWithTimestamp("screencap_%d"))
if err = ioutil.WriteFile(filepath, res, 0o644); err != nil {
return err
}
fmt.Println("screencap saved to", filepath)
return nil
},
}
func init() {
screencapAndroidDevicesCmd.Flags().StringVarP(&serial, "serial", "s", "", "filter by device's serial")
androidRootCmd.AddCommand(screencapAndroidDevicesCmd)
}

View File

@@ -70,18 +70,10 @@ var listDevicesCmd = &cobra.Command{
Short: "List all iOS devices",
PersistentPreRun: func(cmd *cobra.Command, args []string) {},
RunE: func(cmd *cobra.Command, args []string) error {
devices, err := uixt.IOSDevices(udid)
devices, err := uixt.GetIOSDevices(udid)
if err != nil {
return err
}
if len(devices) == 0 {
if udid != "" {
fmt.Printf("no ios device found for udid: %s\n", udid)
os.Exit(1)
} else {
fmt.Println("no ios device found")
os.Exit(0)
}
fmt.Println(err)
os.Exit(0)
}
for _, d := range devices {

View File

@@ -2,7 +2,6 @@ package ios
import (
"fmt"
"os"
"github.com/spf13/cobra"
@@ -16,16 +15,12 @@ var iosRootCmd = &cobra.Command{
}
func getDevice(udid string) (gidevice.Device, error) {
devices, err := uixt.IOSDevices(udid)
devices, err := uixt.GetIOSDevices(udid)
if err != nil {
return nil, err
}
if len(devices) == 0 {
fmt.Println("no ios device found")
os.Exit(1)
}
if len(devices) > 1 {
return nil, fmt.Errorf("multiple devices found, please specify udid")
return nil, fmt.Errorf("found multiple attached devices, please specify ios udid")
}
return devices[0], nil
}

View File

@@ -24,6 +24,8 @@ var Functions = map[string]interface{}{
"get_timestamp": getTimestamp, // call without arguments
"sleep": sleep, // call with one argument
"gen_random_string": genRandomString, // call with one argument
"random_int": rand.Intn, // call with one argument
"random_range": random_range, // call with two arguments
"max": math.Max, // call with two arguments
"md5": MD5, // call with one argument
"parameterize": loadFromCSV,
@@ -49,6 +51,10 @@ func init() {
rand.Seed(time.Now().UnixNano())
}
func random_range(a, b float64) float64 {
return a + rand.Float64()*(b-a)
}
func getTimestamp() int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}

View File

@@ -443,3 +443,10 @@ func Sign(ver string, ak string, sk string, body []byte) string {
signResult := sha256HMAC(signKey, body)
return fmt.Sprintf("%v/%v", signKeyInfo, string(signResult))
}
func GenNameWithTimestamp(tmpl string) string {
if !strings.Contains(tmpl, "%d") {
tmpl = tmpl + "_%d"
}
return fmt.Sprintf(tmpl, time.Now().Unix())
}

View File

@@ -67,8 +67,9 @@ var (
// UI automation related: [70, 80)
var (
MobileUIDriverError = errors.New("mobile UI driver error") // 70
MobileUIValidationError = errors.New("mobile UI validation error") // 75
MobileUIDriverError = errors.New("mobile UI driver error") // 70
MobileUIValidationError = errors.New("mobile UI validation error") // 75
MobileUIAppNotInForegroundError = errors.New("mobile UI app not in foreground error") // 76
)
// OCR related: [80, 90)
@@ -123,8 +124,9 @@ var errorsMap = map[error]int{
AndroidCaptureLogError: 66,
// UI automation related
MobileUIDriverError: 70,
MobileUIValidationError: 75,
MobileUIDriverError: 70,
MobileUIValidationError: 75,
MobileUIAppNotInForegroundError: 76,
// OCR related
OCREnvMissedError: 80,

View File

@@ -1 +1 @@
v4.3.2
v4.3.3

View File

@@ -38,8 +38,8 @@ type iteratorStrategy struct {
PickOrder iteratorPickOrder `json:"pick_order,omitempty" yaml:"pick_order,omitempty"`
}
func initParametersIterator(cfg *TConfig) (*ParametersIterator, error) {
parameters, err := loadParameters(cfg.Parameters, cfg.Variables)
func (p *Parser) initParametersIterator(cfg *TConfig) (*ParametersIterator, error) {
parameters, err := p.loadParameters(cfg.Parameters, cfg.Variables)
if err != nil {
return nil, err
}
@@ -236,7 +236,7 @@ configParameters = {
]
}
*/
func loadParameters(configParameters map[string]interface{}, variablesMapping map[string]interface{}) (
func (p *Parser) loadParameters(configParameters map[string]interface{}, variablesMapping map[string]interface{}) (
map[string]Parameters, error) {
if len(configParameters) == 0 {
@@ -263,7 +263,7 @@ func loadParameters(configParameters map[string]interface{}, variablesMapping ma
// => [["test1", "111111"], ["test2", "222222"]]
// e.g. "app_version": "${gen_app_version()}"
// => ["1.0.0", "1.0.1"]
parsedParameterContent, err := newParser().ParseString(rawValue.String(), variablesMapping)
parsedParameterContent, err := p.ParseString(rawValue.String(), variablesMapping)
if err != nil {
log.Error().Err(err).
Str("parametersRawContent", rawValue.String()).

View File

@@ -74,8 +74,9 @@ func TestLoadParameters(t *testing.T) {
variablesMapping := map[string]interface{}{
"file": "account.csv",
}
parser := newParser()
for _, data := range testData {
value, err := loadParameters(data.configParameters, variablesMapping)
value, err := parser.loadParameters(data.configParameters, variablesMapping)
if !assert.Nil(t, err) {
t.Fatal()
}
@@ -92,21 +93,25 @@ func TestLoadParametersError(t *testing.T) {
{
map[string]interface{}{
"username_password": fmt.Sprintf("${parameterize(%s/account.csv)}", hrpExamplesDir),
"user_agent": []interface{}{"iOS/10.1", "iOS/10.2"}},
"user_agent": []interface{}{"iOS/10.1", "iOS/10.2"},
},
},
{
map[string]interface{}{
"username-password": fmt.Sprintf("${parameterize(%s/account.csv)}", hrpExamplesDir),
"user-agent": []interface{}{"iOS/10.1", "iOS/10.2"}},
"user-agent": []interface{}{"iOS/10.1", "iOS/10.2"},
},
},
{
map[string]interface{}{
"username-password": fmt.Sprintf("${param(%s/account.csv)}", hrpExamplesDir),
"user_agent": []interface{}{"iOS/10.1", "iOS/10.2"}},
"user_agent": []interface{}{"iOS/10.1", "iOS/10.2"},
},
},
}
parser := newParser()
for _, data := range testData {
_, err := loadParameters(data.configParameters, map[string]interface{}{})
_, err := parser.loadParameters(data.configParameters, map[string]interface{}{})
if !assert.Error(t, err) {
t.Fatal()
}
@@ -240,8 +245,9 @@ func TestInitParametersIteratorCount(t *testing.T) {
1,
},
}
parser := newParser()
for _, data := range testData {
iterator, err := initParametersIterator(data.cfg)
iterator, err := parser.initParametersIterator(data.cfg)
if !assert.Nil(t, err) {
t.Fatal()
}
@@ -288,8 +294,9 @@ func TestInitParametersIteratorUnlimitedCount(t *testing.T) {
},
},
}
parser := newParser()
for _, data := range testData {
iterator, err := initParametersIterator(data.cfg)
iterator, err := parser.initParametersIterator(data.cfg)
if !assert.Nil(t, err) {
t.Fatal()
}
@@ -370,8 +377,9 @@ func TestInitParametersIteratorContent(t *testing.T) {
map[string]interface{}{},
},
}
parser := newParser()
for _, data := range testData {
iterator, err := initParametersIterator(data.cfg)
iterator, err := parser.initParametersIterator(data.cfg)
if !assert.Nil(t, err) {
t.Fatal()
}

View File

@@ -1,5 +1,11 @@
# gadb
This module is initially forked from [electricbubble/gadb@v0.0.7].
This module is initially forked from [electricbubble/gadb@v0.0.7] and optimized by [@appl3s].
- feat: add reverse forward command
- feat: add `RunShellCommandV2` which supports running nohup
- feat: add `InstallAPK` with feature judgment
- feat: add `Uninstall` for specified package name
[electricbubble/gadb@v0.0.7]: https://github.com/electricbubble/gadb/tree/v0.0.7
[@appl3s]: https://github.com/appl3s

View File

@@ -38,6 +38,16 @@ func NewClientWith(host string, port ...int) (adbClient Client, err error) {
return
}
func NewClientWithoutTransport(host string, port ...int) (adbClient Client, err error) {
if len(port) == 0 {
port = []int{AdbServerPort}
}
adbClient.host = host
adbClient.port = port[0]
return
}
func (c Client) ServerVersion() (version int, err error) {
var resp string
if resp, err = c.executeCommand("host:version"); err != nil {
@@ -73,14 +83,14 @@ func (c Client) DeviceSerialList() (serials []string, err error) {
return
}
func (c Client) DeviceList() (devices []Device, err error) {
func (c Client) DeviceList() (devices []*Device, err error) {
var resp string
if resp, err = c.executeCommand("host:devices-l"); err != nil {
return
}
lines := strings.Split(resp, "\n")
devices = make([]Device, 0, len(lines))
devices = make([]*Device, 0, len(lines))
for i := range lines {
line := strings.TrimSpace(lines[i])
@@ -101,7 +111,7 @@ func (c Client) DeviceList() (devices []Device, err error) {
key, val := split[0], split[1]
mapAttrs[key] = val
}
devices = append(devices, Device{adbClient: c, serial: fields[0], attrs: mapAttrs})
devices = append(devices, &Device{adbClient: c, serial: fields[0], attrs: mapAttrs})
}
return

View File

@@ -3,6 +3,7 @@
package gadb
import (
"io/ioutil"
"testing"
)
@@ -127,3 +128,22 @@ func TestClient_KillServer(t *testing.T) {
t.Fatal(err)
}
}
func TestScreenCap(t *testing.T) {
adbClient, err := NewClient()
if err != nil {
t.Fatal(err)
}
dl, err := adbClient.DeviceList()
if err != nil {
t.Error(err)
}
d := dl[0]
res, err := d.ScreenCap()
if err != nil {
t.Error(err)
}
t.Log(len(res))
ioutil.WriteFile("/tmp/1.png", res, 0o644)
}

View File

@@ -1,6 +1,8 @@
package gadb
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
@@ -9,6 +11,8 @@ import (
"time"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
)
type DeviceFileInfo struct {
@@ -48,9 +52,10 @@ func deviceStateConv(k string) (deviceState DeviceState) {
}
type DeviceForward struct {
Serial string
Local string
Remote string
Serial string
Local string
Remote string
Reverse bool
// LocalProtocol string
// RemoteProtocol string
}
@@ -59,51 +64,92 @@ type Device struct {
adbClient Client
serial string
attrs map[string]string
feat Features
}
func (d Device) Product() string {
func (d *Device) HasFeature(name Feature) bool {
feats, err := d.GetFeatures()
if err != nil || len(feats) == 0 {
return false
}
return feats.HasFeature(name)
}
func (d *Device) GetFeatures() (features Features, err error) {
if len(d.feat) > 0 {
return d.feat, nil
}
return d.features()
}
func (d *Device) features() (features Features, err error) {
res, err := d.executeCommand("host:features")
if err != nil {
return nil, err
}
if len(res) > 4 {
// stip hash
res = res[4:]
}
fs := strings.Split(string(res), ",")
features = make(Features, len(fs))
for _, f := range fs {
features[Feature(f)] = struct{}{}
}
d.feat = features
return features, nil
}
func (d *Device) Product() string {
return d.attrs["product"]
}
func (d Device) Model() string {
func (d *Device) Model() string {
return d.attrs["model"]
}
func (d Device) Usb() string {
func (d *Device) Usb() string {
return d.attrs["usb"]
}
func (d Device) transportId() string {
func (d *Device) transportId() string {
return d.attrs["transport_id"]
}
func (d Device) DeviceInfo() map[string]string {
func (d *Device) DeviceInfo() map[string]string {
return d.attrs
}
func (d Device) Serial() string {
func (d *Device) Serial() string {
// resp, err := d.adbClient.executeCommand(fmt.Sprintf("host-serial:%s:get-serialno", d.serial))
return d.serial
}
func (d Device) IsUsb() bool {
func (d *Device) IsUsb() bool {
return d.Usb() != ""
}
func (d Device) State() (DeviceState, error) {
func (d *Device) State() (DeviceState, error) {
resp, err := d.adbClient.executeCommand(fmt.Sprintf("host-serial:%s:get-state", d.serial))
return deviceStateConv(resp), err
}
func (d Device) DevicePath() (string, error) {
func (d *Device) DevicePath() (string, error) {
resp, err := d.adbClient.executeCommand(fmt.Sprintf("host-serial:%s:get-devpath", d.serial))
return resp, err
}
func (d Device) Forward(localPort, remotePort int, noRebind ...bool) (err error) {
func (d *Device) Forward(localPort int, remoteInterface interface{}, noRebind ...bool) (err error) {
command := ""
var remote string
local := fmt.Sprintf("tcp:%d", localPort)
remote := fmt.Sprintf("tcp:%d", remotePort)
switch r := remoteInterface.(type) {
// for unix sockets
case string:
remote = r
case int:
remote = fmt.Sprintf("tcp:%d", r)
}
if len(noRebind) != 0 && noRebind[0] {
command = fmt.Sprintf("host-serial:%s:forward:norebind:%s;%s", d.serial, local, remote)
@@ -115,7 +161,7 @@ func (d Device) Forward(localPort, remotePort int, noRebind ...bool) (err error)
return
}
func (d Device) ForwardList() (deviceForwardList []DeviceForward, err error) {
func (d *Device) ForwardList() (deviceForwardList []DeviceForward, err error) {
var forwardList []DeviceForward
if forwardList, err = d.adbClient.ForwardList(); err != nil {
return nil, err
@@ -131,18 +177,80 @@ func (d Device) ForwardList() (deviceForwardList []DeviceForward, err error) {
return
}
func (d Device) ForwardKill(localPort int) (err error) {
func (d *Device) ForwardKill(localPort int) (err error) {
local := fmt.Sprintf("tcp:%d", localPort)
_, err = d.adbClient.executeCommand(fmt.Sprintf("host-serial:%s:killforward:%s", d.serial, local), true)
return
}
func (d Device) RunShellCommand(cmd string, args ...string) (string, error) {
func (d *Device) ReverseForward(localPort int, remoteInterface interface{}, noRebind ...bool) (err error) {
var command string
var remote string
local := fmt.Sprintf("tcp:%d", localPort)
switch r := remoteInterface.(type) {
// for unix sockets
case string:
remote = r
case int:
remote = fmt.Sprintf("tcp:%d", r)
}
if len(noRebind) != 0 && noRebind[0] {
command = fmt.Sprintf("reverse:forward:norebind:%s;%s", remote, local)
} else {
command = fmt.Sprintf("reverse:forward:%s;%s", remote, local)
}
_, err = d.executeCommand(command, true)
return
}
func (d *Device) ReverseForwardList() (deviceForwardList []DeviceForward, err error) {
res, err := d.executeCommand("reverse:list-forward")
if err != nil {
return nil, err
}
resStr := string(res)
lines := strings.Split(resStr, "\n")
for _, line := range lines {
groups := strings.Split(line, " ")
if len(groups) == 3 {
deviceForwardList = append(deviceForwardList, DeviceForward{
Reverse: true,
Serial: d.serial,
Remote: groups[1],
Local: groups[2],
})
}
}
return
}
func (d *Device) ReverseForwardKill(remoteInterface interface{}) error {
remote := ""
switch r := remoteInterface.(type) {
case string:
remote = r
case int:
remote = fmt.Sprintf("tcp:%d", r)
}
_, err := d.executeCommand(fmt.Sprintf("reverse:killforward:%s", remote), true)
return err
}
func (d *Device) ReverseForwardKillAll() error {
_, err := d.executeCommand("reverse:killforward-all")
return err
}
func (d *Device) RunShellCommand(cmd string, args ...string) (string, error) {
raw, err := d.RunShellCommandWithBytes(cmd, args...)
return string(raw), err
}
func (d Device) RunShellCommandWithBytes(cmd string, args ...string) ([]byte, error) {
func (d *Device) RunShellCommandWithBytes(cmd string, args ...string) ([]byte, error) {
if d.HasFeature(FeatShellV2) {
return d.RunShellCommandV2WithBytes(cmd, args...)
}
if len(args) > 0 {
cmd = fmt.Sprintf("%s %s", cmd, strings.Join(args, " "))
}
@@ -156,7 +264,86 @@ func (d Device) RunShellCommandWithBytes(cmd string, args ...string) ([]byte, er
return raw, err
}
func (d Device) EnableAdbOverTCP(port ...int) (err error) {
func (d *Device) RunShellCommandV2(cmd string, args ...string) (string, error) {
raw, err := d.RunShellCommandV2WithBytes(cmd, args...)
return string(raw), err
}
// RunShellCommandV2WithBytes shell v2, 支持后台运行而不会阻断
func (d *Device) RunShellCommandV2WithBytes(cmd string, args ...string) ([]byte, error) {
if len(args) > 0 {
cmd = fmt.Sprintf("%s %s", cmd, strings.Join(args, " "))
}
if strings.TrimSpace(cmd) == "" {
return nil, errors.New("adb shell: command cannot be empty")
}
log.Debug().Str("cmd",
fmt.Sprintf("adb -s %s shell %s", d.serial, cmd)).
Msg("run adb command in v2")
raw, err := d.executeCommand(fmt.Sprintf("shell,v2,raw:%s", cmd))
if err != nil {
return raw, err
}
return d.parseV2CommandWithBytes(raw)
}
func (d *Device) parseV2CommandWithBytes(input []byte) (res []byte, err error) {
if len(input) == 0 {
return input, nil
}
reader := bytes.NewReader(input)
sizeBuf := make([]byte, 4)
var (
resBuf []byte
exitCode int
)
loop:
for {
msgCode, err := reader.ReadByte()
if err != nil {
return input, err
}
switch msgCode {
case 0x01, 0x02: // STDOUT, STDERR
_, err = io.ReadFull(reader, sizeBuf)
if err != nil {
return input, err
}
size := binary.LittleEndian.Uint32(sizeBuf)
if cap(resBuf) < int(size) {
resBuf = make([]byte, int(size))
}
_, err = io.ReadFull(reader, resBuf[:size])
if err != nil {
return input, err
}
res = append(res, resBuf[:size]...)
case 0x03: // EXIT
_, err = io.ReadFull(reader, sizeBuf)
if err != nil {
return input, err
}
size := binary.LittleEndian.Uint32(sizeBuf)
if cap(resBuf) < int(size) {
resBuf = make([]byte, int(size))
}
ec, err := reader.ReadByte()
if err != nil {
return input, err
}
exitCode = int(ec)
break loop
default:
return input, nil
}
}
if exitCode != 0 {
return nil, errors.New(string(res))
}
return res, nil
}
func (d *Device) EnableAdbOverTCP(port ...int) (err error) {
if len(port) == 0 {
port = []int{AdbDaemonPort}
}
@@ -168,7 +355,7 @@ func (d Device) EnableAdbOverTCP(port ...int) (err error) {
return
}
func (d Device) createDeviceTransport() (tp transport, err error) {
func (d *Device) createDeviceTransport() (tp transport, err error) {
if tp, err = newTransport(fmt.Sprintf("%s:%d", d.adbClient.host, d.adbClient.port)); err != nil {
return transport{}, err
}
@@ -180,7 +367,7 @@ func (d Device) createDeviceTransport() (tp transport, err error) {
return
}
func (d Device) executeCommand(command string, onlyVerifyResponse ...bool) (raw []byte, err error) {
func (d *Device) executeCommand(command string, onlyVerifyResponse ...bool) (raw []byte, err error) {
if len(onlyVerifyResponse) == 0 {
onlyVerifyResponse = []bool{false}
}
@@ -207,7 +394,7 @@ func (d Device) executeCommand(command string, onlyVerifyResponse ...bool) (raw
return
}
func (d Device) List(remotePath string) (devFileInfos []DeviceFileInfo, err error) {
func (d *Device) List(remotePath string) (devFileInfos []DeviceFileInfo, err error) {
var tp transport
if tp, err = d.createDeviceTransport(); err != nil {
return nil, err
@@ -237,7 +424,7 @@ func (d Device) List(remotePath string) (devFileInfos []DeviceFileInfo, err erro
return
}
func (d Device) PushFile(local *os.File, remotePath string, modification ...time.Time) (err error) {
func (d *Device) PushFile(local *os.File, remotePath string, modification ...time.Time) (err error) {
if len(modification) == 0 {
var stat os.FileInfo
if stat, err = local.Stat(); err != nil {
@@ -249,7 +436,7 @@ func (d Device) PushFile(local *os.File, remotePath string, modification ...time
return d.Push(local, remotePath, modification[0], DefaultFileMode)
}
func (d Device) Push(source io.Reader, remotePath string, modification time.Time, mode ...os.FileMode) (err error) {
func (d *Device) Push(source io.Reader, remotePath string, modification time.Time, mode ...os.FileMode) (err error) {
if len(mode) == 0 {
mode = []os.FileMode{DefaultFileMode}
}
@@ -285,7 +472,7 @@ func (d Device) Push(source io.Reader, remotePath string, modification time.Time
return
}
func (d Device) Pull(remotePath string, dest io.Writer) (err error) {
func (d *Device) Pull(remotePath string, dest io.Writer) (err error) {
var tp transport
if tp, err = d.createDeviceTransport(); err != nil {
return err
@@ -305,3 +492,102 @@ func (d Device) Pull(remotePath string, dest io.Writer) (err error) {
err = sync.WriteStream(dest)
return
}
func (d *Device) installViaABBExec(apk io.ReadSeeker) (raw []byte, err error) {
var (
tp transport
filesize int64
)
filesize, err = apk.Seek(0, io.SeekEnd)
if err != nil {
return nil, err
}
if tp, err = d.createDeviceTransport(); err != nil {
return nil, err
}
defer func() { _ = tp.Close() }()
if err = tp.Send(fmt.Sprintf("abb_exec:package\x00install\x00-t\x00-S\x00%d", filesize)); err != nil {
return nil, err
}
if err = tp.VerifyResponse(); err != nil {
return nil, err
}
_, err = apk.Seek(0, io.SeekStart)
if err != nil {
return nil, err
}
_, err = io.Copy(tp.Conn(), apk)
if err != nil {
return nil, err
}
raw, err = tp.ReadBytesAll()
return
}
func (d *Device) InstallAPK(apk io.ReadSeeker) (string, error) {
haserr := func(ret string) bool {
return strings.Contains(ret, "Failure")
}
if d.HasFeature(FeatAbbExec) {
raw, err := d.installViaABBExec(apk)
if err != nil {
return "", fmt.Errorf("error installing: %v", err)
}
if haserr(string(raw)) {
return "", errors.New(string(raw))
}
return string(raw), err
}
remote := fmt.Sprintf("/data/local/tmp/%s.apk", builtin.GenNameWithTimestamp("gadb_remote_%d"))
err := d.Push(apk, remote, time.Now())
if err != nil {
return "", fmt.Errorf("error pushing: %v", err)
}
res, err := d.RunShellCommand("pm", "install", "-f", remote)
if err != nil {
return "", fmt.Errorf("error installing: %v", err)
}
if haserr(res) {
return "", errors.New(res)
}
return res, nil
}
func (d *Device) Uninstall(packageName string, keepData ...bool) (string, error) {
if len(keepData) == 0 {
keepData = []bool{false}
}
packageName = strings.ReplaceAll(packageName, " ", "")
if len(packageName) == 0 {
return "", fmt.Errorf("invalid package name")
}
args := []string{"uninstall"}
if keepData[0] {
args = append(args, "-k")
}
args = append(args, packageName)
return d.RunShellCommandV2("pm", args...)
}
func (d *Device) ScreenCap() ([]byte, error) {
if d.HasFeature(FeatShellV2) {
return d.RunShellCommandV2WithBytes("screencap", "-p")
}
// for shell v1, screenshot buffer maybe truncated
// thus we firstly save it to local file and then pull it
tempPath := fmt.Sprintf("/data/local/tmp/screenshot_%d.png",
time.Now().Unix())
_, err := d.RunShellCommandWithBytes("screencap", "-p", tempPath)
if err != nil {
return nil, err
}
buffer := bytes.NewBuffer(nil)
err = d.Pull(tempPath, buffer)
return buffer.Bytes(), err
}

View File

@@ -6,6 +6,7 @@ import (
"bytes"
"io/ioutil"
"os"
"reflect"
"strings"
"testing"
"time"
@@ -145,6 +146,42 @@ func TestDevice_Forward(t *testing.T) {
}
}
func TestDevice_ReverseForward(t *testing.T) {
adbClient, err := NewClient()
if err != nil {
t.Fatal(err)
}
devices, err := adbClient.DeviceList()
if err != nil {
t.Fatal(err)
}
localPort := 5005
err = devices[0].ReverseForward(localPort, "localabstract:scrcpy")
if err != nil {
t.Fatal(err)
}
err = devices[0].ReverseForward(localPort, "localabstract:scrcpy1")
if err != nil {
t.Fatal(err)
}
_, err = devices[0].ReverseForwardList()
if err != nil {
t.Fatal(err)
}
err = devices[0].ReverseForwardKill("localabstract:scrcpy1")
if err != nil {
t.Fatal(err)
}
err = devices[0].ReverseForwardKillAll()
if err != nil {
t.Fatal(err)
}
}
func TestDevice_ForwardList(t *testing.T) {
adbClient, err := NewClient()
if err != nil {
@@ -314,3 +351,94 @@ func TestDevice_Pull(t *testing.T) {
t.Fatal(err)
}
}
func TestDevice_RunShellCommandBackgroundWithBytes(t *testing.T) {
type fields struct {
adbClient Client
serial string
attrs map[string]string
}
type args struct {
cmd string
args []string
}
tests := []struct {
name string
fields fields
args args
want []byte
wantErr bool
}{
{
name: "runShellCommandBackground",
fields: fields{
adbClient: func() Client {
c, _ := NewClient()
return c
}(),
serial: "63c1ee94",
},
args: args{
cmd: "nohup sleep 10 2>/dev/null 1>/dev/null &",
// cmd: "sleep 10",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := Device{
adbClient: tt.fields.adbClient,
serial: tt.fields.serial,
attrs: tt.fields.attrs,
}
got, err := d.RunShellCommandV2WithBytes(tt.args.cmd, tt.args.args...)
if (err != nil) != tt.wantErr {
t.Errorf("Device.RunShellCommandBackgroundWithBytes() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Device.RunShellCommandBackgroundWithBytes() = %v, want %v", got, tt.want)
}
})
}
}
func TestDevice_InstallAPK(t *testing.T) {
apk, _ := os.Open("test.apk")
adbClient, err := NewClient()
if err != nil {
t.Fatal(err)
}
devices, err := adbClient.DeviceList()
if err != nil {
t.Fatal(err)
}
dev := devices[len(devices)-1]
dev = devices[0]
res, err := dev.InstallAPK(apk)
if err != nil {
t.Fatal(err)
}
t.Log(res)
}
func TestDevice_HasFeature(t *testing.T) {
adbClient, err := NewClient()
if err != nil {
t.Fatal(err)
}
devices, err := adbClient.DeviceList()
if err != nil {
t.Fatal(err)
}
dev := devices[len(devices)-1]
dev = devices[0]
t.Log(dev.GetFeatures())
}

26
hrp/pkg/gadb/features.go Normal file
View File

@@ -0,0 +1,26 @@
package gadb
type (
Feature string
Features map[Feature]struct{}
)
var (
FeatSendrecvV2Brotli = Feature("sendrecv_v2_brotli")
FeatRemountShell = Feature("remount_shell")
FeatSendrecvV2 = Feature("sendrecv_v2")
FeatAbbExec = Feature("abb_exec")
FeatFixedPushMkdir = Feature("fixed_push_mkdir")
FeatFixedPushSymlinkTimestamp = Feature("fixed_push_symlink_timestamp")
FeatAbb = Feature("abb")
FeatShellV2 = Feature("shell_v2")
FeatCmd = Feature("cmd")
FeatLsV2 = Feature("ls_v2")
FeatApex = Feature("apex")
FeatStatV2 = Feature("stat_v2")
)
func (fs Features) HasFeature(name Feature) bool {
_, has := fs[name]
return has
}

View File

@@ -250,5 +250,6 @@ func (sync syncTransport) Close() (err error) {
if sync.sock == nil {
return nil
}
_ = DisableTimeWait(sync.sock.(*net.TCPConn))
return sync.sock.Close()
}

View File

@@ -37,6 +37,14 @@ func (t transport) Send(command string) (err error) {
return _send(t.sock, []byte(msg))
}
func (t transport) SendBytes(b []byte) (err error) {
return _send(t.sock, b)
}
func (t transport) Conn() net.Conn {
return t.sock
}
func (t transport) VerifyResponse() (err error) {
var status string
if status, err = t.ReadStringN(4); err != nil {
@@ -103,6 +111,7 @@ func (t transport) Close() (err error) {
if t.sock == nil {
return nil
}
_ = DisableTimeWait(t.sock.(*net.TCPConn))
return t.sock.Close()
}

View File

@@ -13,7 +13,6 @@ func Test_transport_VerifyResponse(t *testing.T) {
}
defer transport.Close()
// err = transport.Send("host:123version")
err = transport.Send("host:version")
if err != nil {
t.Fatal(err)

9
hrp/pkg/gadb/utils.go Normal file
View File

@@ -0,0 +1,9 @@
package gadb
import (
"net"
)
func DisableTimeWait(conn *net.TCPConn) error {
return conn.SetLinger(0)
}

View File

@@ -17,7 +17,7 @@ import (
type adbDriver struct {
Driver
adbClient gadb.Device
adbClient *gadb.Device
logcat *AdbLogcat
}
@@ -153,11 +153,11 @@ func (ad *adbDriver) PressKeyCode(keyCode KeyCode, metaState KeyMeta) (err error
return
}
func (ad *adbDriver) AppLaunch(bundleId string) (err error) {
func (ad *adbDriver) AppLaunch(packageName string) (err error) {
// 不指定 Activity 名称启动(启动主 Activity
// adb shell monkey -p <packagename> -c android.intent.category.LAUNCHER 1
sOutput, err := ad.adbClient.RunShellCommand(
"monkey", "-p", bundleId, "-c", "android.intent.category.LAUNCHER", "1",
"monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1",
)
if err != nil {
return err
@@ -165,14 +165,22 @@ func (ad *adbDriver) AppLaunch(bundleId string) (err error) {
if strings.Contains(sOutput, "monkey aborted") {
return fmt.Errorf("app launch: %s", strings.TrimSpace(sOutput))
}
ad.lastLaunchedPackageName = packageName
return nil
}
func (ad *adbDriver) AppTerminate(bundleId string) (successful bool, err error) {
func (ad *adbDriver) AppTerminate(packageName string) (successful bool, err error) {
// 强制停止应用,停止 <packagename> 相关的进程
// adb shell am force-stop <packagename>
_, err = ad.adbClient.RunShellCommand("am", "force-stop", bundleId)
return err == nil, err
_, err = ad.adbClient.RunShellCommand("am", "force-stop", packageName)
if err != nil {
return false, err
}
if ad.lastLaunchedPackageName == packageName {
ad.lastLaunchedPackageName = "" // reset last launched package name
}
return true, nil
}
func (ad *adbDriver) Tap(x, y int, options ...DataOption) error {
@@ -180,6 +188,13 @@ func (ad *adbDriver) Tap(x, y int, options ...DataOption) error {
}
func (ad *adbDriver) TapFloat(x, y float64, options ...DataOption) (err error) {
dataOptions := NewDataOptions(options...)
if len(dataOptions.Offset) == 2 {
x += float64(dataOptions.Offset[0])
y += float64(dataOptions.Offset[1])
}
// adb shell input tap x y
_, err = ad.adbClient.RunShellCommand(
"input", "tap", fmt.Sprintf("%.1f", x), fmt.Sprintf("%.1f", y))
@@ -273,9 +288,7 @@ func (ad *adbDriver) SetRotation(rotation Rotation) (err error) {
func (ad *adbDriver) Screenshot() (raw *bytes.Buffer, err error) {
// adb shell screencap -p
resp, err := ad.adbClient.RunShellCommandWithBytes(
"screencap", "-p",
)
resp, err := ad.adbClient.ScreenCap()
if err == nil {
return bytes.NewBuffer(resp), nil
}
@@ -314,6 +327,13 @@ func (ad *adbDriver) IsHealthy() (healthy bool, err error) {
func (ad *adbDriver) StartCaptureLog(identifier ...string) (err error) {
log.Info().Msg("start adb log recording")
// clear logcat
if _, err = ad.adbClient.RunShellCommand("logcat", "-c"); err != nil {
return err
}
// start logcat
err = ad.logcat.CatchLogcat()
if err != nil {
err = errors.Wrap(code.AndroidCaptureLogError,
@@ -335,3 +355,35 @@ func (ad *adbDriver) StopCaptureLog() (result interface{}, err error) {
content := ad.logcat.logBuffer.String()
return ConvertPoints(content), nil
}
func (ad *adbDriver) GetLastLaunchedApp() (packageName string) {
return ad.lastLaunchedPackageName
}
func (ad *adbDriver) IsAppInForeground(packageName string) (bool, error) {
if packageName == "" {
return false, errors.New("package name is not given")
}
// adb shell dumpsys activity activities | grep mResumedActivity
output, err := ad.adbClient.RunShellCommand("dumpsys", "activity", "activities")
if err != nil {
log.Error().Err(err).Msg("failed to dumpsys activities")
return false, err
}
lines := strings.Split(string(output), "\n")
isInForeground := false
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if strings.HasPrefix(trimmedLine, "mResumedActivity:") {
if strings.Contains(trimmedLine, packageName) {
isInForeground = true
}
break
}
}
return isInForeground, nil
}

View File

@@ -81,15 +81,6 @@ func GetAndroidDeviceOptions(dev *AndroidDevice) (deviceOptions []AndroidDeviceO
// uiautomator2 server must be started before
// adb shell am instrument -w io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner
func NewAndroidDevice(options ...AndroidDeviceOption) (device *AndroidDevice, err error) {
deviceList, err := DeviceList()
if err != nil {
return nil, errors.Wrap(code.AndroidDeviceConnectionError,
fmt.Sprintf("get attached devices failed: %v", err))
} else if len(deviceList) == 0 {
return nil, errors.Wrap(code.AndroidDeviceConnectionError,
"not attached device found")
}
device = &AndroidDevice{
UIA2IP: UIA2ServerHost,
UIA2Port: UIA2ServerPort,
@@ -98,34 +89,56 @@ func NewAndroidDevice(options ...AndroidDeviceOption) (device *AndroidDevice, er
option(device)
}
serialNumber := device.SerialNumber
for _, dev := range deviceList {
// find device by serial number if specified
if serialNumber != "" && dev.Serial() != serialNumber {
continue
}
device.SerialNumber = dev.Serial()
device.d = dev
device.logcat = NewAdbLogcat(device.SerialNumber)
return device, nil
deviceList, err := GetAndroidDevices(device.SerialNumber)
if err != nil {
return nil, errors.Wrap(code.AndroidDeviceConnectionError, err.Error())
}
return nil, errors.Wrap(code.AndroidDeviceConnectionError,
fmt.Sprintf("device %s not found", device.SerialNumber))
dev := deviceList[0]
device.SerialNumber = dev.Serial()
device.d = dev
device.logcat = NewAdbLogcat(device.SerialNumber)
log.Info().Str("serial", device.SerialNumber).Msg("select android device")
return device, nil
}
func DeviceList() (devices []gadb.Device, err error) {
func GetAndroidDevices(serial ...string) (devices []*gadb.Device, err error) {
var adbClient gadb.Client
if adbClient, err = gadb.NewClientWith(AdbServerHost, AdbServerPort); err != nil {
return nil, errors.Wrap(code.AndroidDeviceConnectionError, err.Error())
}
return adbClient.DeviceList()
if devices, err = adbClient.DeviceList(); err != nil {
return nil, errors.Wrap(code.AndroidDeviceConnectionError,
fmt.Sprintf("list android devices failed: %v", err))
}
var deviceList []*gadb.Device
// filter by serial
for _, d := range devices {
for _, s := range serial {
if s != "" && s != d.Serial() {
continue
}
deviceList = append(deviceList, d)
}
}
if len(deviceList) == 0 {
var err error
if serial == nil || (len(serial) == 1 && serial[0] == "") {
err = fmt.Errorf("no android device found")
} else {
err = fmt.Errorf("no android device found for serial %v", serial)
}
return nil, err
}
return deviceList, nil
}
type AndroidDevice struct {
d gadb.Device
d *gadb.Device
logcat *AdbLogcat
SerialNumber string `json:"serial,omitempty" yaml:"serial,omitempty"`
UIA2 bool `json:"uia2,omitempty" yaml:"uia2,omitempty"` // use uiautomator2
@@ -138,6 +151,10 @@ func (dev *AndroidDevice) UUID() string {
return dev.SerialNumber
}
func (dev *AndroidDevice) LogEnabled() bool {
return dev.LogOn
}
func (dev *AndroidDevice) NewDriver(capabilities Capabilities) (driverExt *DriverExt, err error) {
var driver WebDriver
if dev.UIA2 {
@@ -311,12 +328,13 @@ func (l *AdbLogcat) CatchLogcat() (err error) {
}
// clear logcat
if err = myexec.RunCommand("adb", "-s", l.serial, "logcat", "-c"); err != nil {
if err = myexec.RunCommand("adb", "-s", l.serial, "shell", "logcat", "-c"); err != nil {
return
}
// start logcat
l.cmd = myexec.Command("adb", "-s", l.serial, "logcat", "-v", "time", "-s", "iesqaMonitor:V")
l.cmd = myexec.Command("adb", "-s", l.serial,
"logcat", "--format", "time", "-s", "iesqaMonitor:V")
l.cmd.Stderr = l.logBuffer
l.cmd.Stdout = l.logBuffer
if err = l.cmd.Start(); err != nil {
@@ -325,7 +343,7 @@ func (l *AdbLogcat) CatchLogcat() (err error) {
go func() {
<-l.stopping
if e := myexec.KillProcessesByGpid(l.cmd); e != nil {
l.errs = append(l.errs, fmt.Errorf("kill logcat process err:%v", e))
log.Error().Err(e).Msg("kill logcat process failed")
}
l.done <- struct{}{}
}()

View File

@@ -324,7 +324,7 @@ func Test_getFreePort(t *testing.T) {
}
func TestDeviceList(t *testing.T) {
devices, err := DeviceList()
devices, err := GetAndroidDevices()
if err != nil {
t.Fatal(err)
}
@@ -353,6 +353,34 @@ func TestDriver_AppLaunch(t *testing.T) {
t.Log(ioutil.WriteFile("s1.png", raw.Bytes(), 0o600))
}
func TestDriver_IsAppInForeground(t *testing.T) {
device, _ := NewAndroidDevice()
driver, err := device.NewDriver(nil)
if err != nil {
t.Fatal(err)
}
err = driver.Driver.AppLaunch("com.android.settings")
if err != nil {
t.Fatal(err)
}
yes, err := driver.Driver.IsAppInForeground(driver.Driver.GetLastLaunchedApp())
if err != nil || !yes {
t.Fatal(err)
}
_, err = driver.Driver.AppTerminate("com.android.settings")
if err != nil {
t.Fatal(err)
}
yes, err = driver.Driver.IsAppInForeground("com.android.settings")
if err != nil || yes {
t.Fatal(err)
}
}
func TestDriver_KeepAlive(t *testing.T) {
device, _ := NewAndroidDevice()
driver, err := device.NewDriver(nil)

View File

@@ -20,6 +20,8 @@ type Driver struct {
urlPrefix *url.URL
sessionId string
client *http.Client
// cache the last launched package name
lastLaunchedPackageName string
}
func (wd *Driver) concatURL(u *url.URL, elem ...string) string {

View File

@@ -5,8 +5,10 @@ import (
"encoding/json"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"math/rand"
"mime"
"mime/multipart"
"net/http"
@@ -32,16 +34,22 @@ const (
AppStop MobileMethod = "app_stop"
CtlScreenShot MobileMethod = "screenshot"
CtlSleep MobileMethod = "sleep"
CtlSleepRandom MobileMethod = "sleep_random"
CtlStartCamera MobileMethod = "camera_start" // alias for app_launch camera
CtlStopCamera MobileMethod = "camera_stop" // alias for app_terminate camera
RecordStart MobileMethod = "record_start"
RecordStop MobileMethod = "record_stop"
// UI validation
SelectorName string = "ui_name"
SelectorLabel string = "ui_label"
SelectorOCR string = "ui_ocr"
SelectorImage string = "ui_image"
// 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"
@@ -209,7 +217,7 @@ type DriverExt struct {
doneMjpegStream chan bool
scale float64
ocrService OCRService // used to get text from image
ScreenShots []string // save screenshots path
screenShots []string // cache screenshot paths
CVArgs
}
@@ -245,7 +253,9 @@ func NewDriverExt(device Device, driver WebDriver) (dExt *DriverExt, err error)
return dExt, nil
}
func (dExt *DriverExt) takeScreenShot() (raw *bytes.Buffer, err error) {
// TakeScreenShot takes screenshot and saves image file to $CWD/screenshots/ folder
// if fileName is empty, it will not save image file and only return raw image data
func (dExt *DriverExt) TakeScreenShot(fileName ...string) (raw *bytes.Buffer, err error) {
// wait for action done
time.Sleep(500 * time.Millisecond)
@@ -255,15 +265,34 @@ func (dExt *DriverExt) takeScreenShot() (raw *bytes.Buffer, err error) {
return dExt.frame, nil
}
if raw, err = dExt.Driver.Screenshot(); err != nil {
log.Error().Err(err).Msg("takeScreenShot failed")
log.Error().Err(err).Msg("capture screenshot data failed")
return nil, err
}
// save screenshot to file
if len(fileName) > 0 && fileName[0] != "" {
path := filepath.Join(env.ScreenShotsPath, fileName[0])
path, err := dExt.saveScreenShot(raw, path)
if err != nil {
log.Error().Err(err).Msg("save screenshot file failed")
return nil, err
}
dExt.screenShots = append(dExt.screenShots, path)
log.Info().Str("path", path).Msg("save screenshot file success")
}
return raw, nil
}
// saveScreenShot saves image file with file name
func saveScreenShot(raw *bytes.Buffer, fileName string) (string, error) {
img, format, err := image.Decode(raw)
func (dExt *DriverExt) 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")
}
@@ -282,6 +311,8 @@ func saveScreenShot(raw *bytes.Buffer, fileName string) (string, error) {
err = png.Encode(file, img)
case "jpeg":
err = jpeg.Encode(file, img, nil)
case "gif":
err = gif.Encode(file, img, nil)
default:
return "", fmt.Errorf("unsupported image format: %s", format)
}
@@ -292,19 +323,11 @@ func saveScreenShot(raw *bytes.Buffer, fileName string) (string, error) {
return screenshotPath, nil
}
// ScreenShot takes screenshot and saves image file to $CWD/screenshots/ folder
func (dExt *DriverExt) ScreenShot(fileName string) (string, error) {
raw, err := dExt.takeScreenShot()
if err != nil {
return "", errors.Wrap(err, "screenshot failed")
}
fileName = filepath.Join(env.ScreenShotsPath, fileName)
path, err := saveScreenShot(raw, fileName)
if err != nil {
return "", errors.Wrap(err, "save screenshot failed")
}
return path, nil
func (dExt *DriverExt) GetScreenShots() []string {
defer func() {
dExt.screenShots = nil
}()
return dExt.screenShots
}
// isPathExists returns true if path exists, whether path is file or dir
@@ -315,6 +338,10 @@ func isPathExists(path string) bool {
return true
}
func init() {
rand.Seed(time.Now().UnixNano())
}
func (dExt *DriverExt) FindUIRectInUIKit(search string, options ...DataOption) (x, y, width, height float64, err error) {
// click on text, using OCR
if !isPathExists(search) {
@@ -340,8 +367,31 @@ func (dExt *DriverExt) IsImageExist(text string) bool {
return err == nil
}
func (dExt *DriverExt) IsAppInForeground(packageName string) bool {
// check if app is in foreground
yes, err := dExt.Driver.IsAppInForeground(packageName)
if !yes || err != nil {
log.Info().Str("packageName", packageName).Msg("app is not in foreground")
return false
}
return true
}
var errActionNotImplemented = errors.New("UI action not implemented")
func convertToFloat64(val interface{}) (float64, error) {
switch v := val.(type) {
case float64:
return v, nil
case int:
return float64(v), nil
case int64:
return float64(v), nil
default:
return 0, fmt.Errorf("invalid type for conversion to float64: %T, value: %+v", val, val)
}
}
func (dExt *DriverExt) DoAction(action MobileAction) error {
log.Info().Str("method", string(action.Method)).Interface("params", action.Params).Msg("start UI action")
@@ -602,16 +652,59 @@ func (dExt *DriverExt) DoAction(action MobileAction) error {
return nil
}
return fmt.Errorf("invalid sleep params: %v(%T)", action.Params, action.Params)
case CtlScreenShot:
// take snapshot
log.Info().Msg("take snapshot for current screen")
screenshotPath, err := dExt.ScreenShot(fmt.Sprintf("screenshot_%d",
time.Now().Unix()))
if err != nil {
return errors.Wrap(err, "take screenshot failed")
case CtlSleepRandom:
params, ok := action.Params.([]interface{})
if !ok {
return fmt.Errorf("invalid sleep random params: %v(%T)", action.Params, action.Params)
}
log.Info().Str("path", screenshotPath).Msg("take screenshot")
dExt.ScreenShots = append(dExt.ScreenShots, screenshotPath)
// append default weight 1
if len(params) == 2 {
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, err := convertToFloat64(params[i])
if err != nil {
return errors.Wrapf(err, "invalid minimum time: %v", params[i])
}
max, err := convertToFloat64(params[i+1])
if err != nil {
return errors.Wrapf(err, "invalid maximum time: %v", params[i+1])
}
weight, err := convertToFloat64(params[i+2])
if err != nil {
return errors.Wrapf(err, "invalid weight value: %v", 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 sleep")
return nil
}
r := rand.Float64()
accProb := 0.0
for _, s := range sections {
accProb += s.weight / totalProb
if r < accProb {
n := s.min + rand.Float64()*(s.max-s.min)
log.Info().Float64("duration", n).Msg("sleep random seconds")
time.Sleep(time.Duration(n*1000) * time.Millisecond)
return nil
}
}
case CtlScreenShot:
// take screenshot
log.Info().Msg("take screenshot for current screen")
_, err := dExt.TakeScreenShot(builtin.GenNameWithTimestamp("step_%d_screenshot"))
return err
case CtlStartCamera:
return dExt.Driver.StartCamera()
@@ -629,18 +722,20 @@ func (dExt *DriverExt) getAbsScope(x1, y1, x2, y2 float64) (int, int, int, int)
}
func (dExt *DriverExt) DoValidation(check, assert, expected string, message ...string) bool {
var exists bool
if assert == AssertionExists {
exists = true
var exp bool
if assert == AssertionExists || assert == AssertionEqual {
exp = true
} else {
exists = false
exp = false
}
var result bool
switch check {
case SelectorOCR:
result = (dExt.IsOCRExist(expected) == exists)
result = (dExt.IsOCRExist(expected) == exp)
case SelectorImage:
result = (dExt.IsImageExist(expected) == exists)
result = (dExt.IsImageExist(expected) == exp)
case SelectorForegroundApp:
result = (dExt.IsAppInForeground(expected) == exp)
}
if !result {

View File

@@ -2,7 +2,6 @@ package uixt
import (
"bytes"
"fmt"
"math"
"strings"
"time"
@@ -437,7 +436,6 @@ type DataOptions struct {
IgnoreNotFoundError bool // ignore error if target element not found
MaxRetryTimes int // max retry times if target element not found
Interval float64 // interval between retries in seconds
ScreenShotFilename string // turn on screenshot and specify file name
}
type DataOption func(data *DataOptions)
@@ -514,16 +512,6 @@ func WithDataWaitTime(sec float64) DataOption {
}
}
func WithScreenShot(fileName ...string) DataOption {
return func(data *DataOptions) {
if len(fileName) > 0 {
data.ScreenShotFilename = fileName[0]
} else {
data.ScreenShotFilename = fmt.Sprintf("screenshot_%d", time.Now().Unix())
}
}
}
func NewDataOptions(options ...DataOption) *DataOptions {
dataOptions := &DataOptions{
Data: make(map[string]interface{}),
@@ -581,6 +569,7 @@ func NewData(data map[string]interface{}, options ...DataOption) map[string]inte
// current implemeted device: IOSDevice, AndroidDevice
type Device interface {
UUID() string // ios udid or android serial
LogEnabled() bool
NewDriver(capabilities Capabilities) (driverExt *DriverExt, err error)
StartPerf() error
@@ -626,10 +615,14 @@ type WebDriver interface {
// AppLaunch Launch an application with given bundle identifier in scope of current session.
// !This method is only available since Xcode9 SDK
AppLaunch(bundleId string) error
// AppTerminate Terminate an application with the given bundle id.
AppLaunch(packageName string) error
// AppTerminate Terminate an application with the given pacakge name.
// Either `true` if the app has been successfully terminated or `false` if it was not running
AppTerminate(bundleId string) (bool, error)
AppTerminate(packageName string) (bool, error)
// GetLastLaunchedApp returns the package name of the last launched app
GetLastLaunchedApp() string
// IsAppInForeground returns true if the given package is in foreground
IsAppInForeground(packageName string) (bool, error)
// StartCamera Starts a new camera for recording
StartCamera() error

View File

@@ -141,7 +141,7 @@ func WithIOSPcapOptions(options ...gidevice.PcapOption) IOSDeviceOption {
}
}
func IOSDevices(udid ...string) (devices []gidevice.Device, err error) {
func GetIOSDevices(udid ...string) (devices []gidevice.Device, err error) {
var usbmux gidevice.Usbmux
if usbmux, err = gidevice.NewUsbmux(); err != nil {
return nil, errors.Wrap(code.IOSDeviceConnectionError,
@@ -168,6 +168,15 @@ func IOSDevices(udid ...string) (devices []gidevice.Device, err error) {
}
}
if len(deviceList) == 0 {
var err error
if udid == nil || (len(udid) == 1 && udid[0] == "") {
err = fmt.Errorf("no ios device found")
} else {
err = fmt.Errorf("no ios device found for udid %v", udid)
}
return nil, err
}
return deviceList, nil
}
@@ -223,31 +232,27 @@ func NewIOSDevice(options ...IOSDeviceOption) (device *IOSDevice, err error) {
option(device)
}
deviceList, err := IOSDevices(device.UDID)
deviceList, err := GetIOSDevices(device.UDID)
if err != nil {
return nil, err
return nil, errors.Wrap(code.IOSDeviceConnectionError, err.Error())
}
for _, dev := range deviceList {
udid := dev.Properties().SerialNumber
device.UDID = udid
device.d = dev
dev := deviceList[0]
udid := dev.Properties().SerialNumber
device.UDID = udid
device.d = dev
// run xctest if XCTestBundleID is set
if device.XCTestBundleID != "" {
_, err = device.RunXCTest(device.XCTestBundleID)
if err != nil {
log.Error().Err(err).Str("udid", udid).Msg("failed to init XCTest")
continue
}
// run xctest if XCTestBundleID is set
if device.XCTestBundleID != "" {
_, err = device.RunXCTest(device.XCTestBundleID)
if err != nil {
log.Error().Err(err).Str("udid", udid).Msg("failed to init XCTest")
return
}
log.Info().Str("udid", device.UDID).Msg("select device")
return device, nil
}
return nil, errors.Wrap(code.IOSDeviceConnectionError,
fmt.Sprintf("device %s not found", device.UDID))
log.Info().Str("udid", device.UDID).Msg("select ios device")
return device, nil
}
type IOSDevice struct {
@@ -281,6 +286,10 @@ func (dev *IOSDevice) UUID() string {
return dev.UDID
}
func (dev *IOSDevice) LogEnabled() bool {
return dev.LogOn
}
func (dev *IOSDevice) NewDriver(capabilities Capabilities) (driverExt *DriverExt, err error) {
// init WDA driver
if capabilities == nil {

View File

@@ -308,6 +308,9 @@ func (wd *wdaDriver) AppLaunch(bundleId string) (err error) {
data := make(map[string]interface{})
data["bundleId"] = bundleId
_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/apps/launch")
if err == nil {
wd.lastLaunchedPackageName = bundleId
}
return
}
@@ -328,6 +331,9 @@ func (wd *wdaDriver) AppTerminate(bundleId string) (successful bool, err error)
if successful, err = rawResp.valueConvertToBool(); err != nil {
return false, err
}
if wd.lastLaunchedPackageName == bundleId {
wd.lastLaunchedPackageName = "" // reset last launched package name
}
return
}
@@ -348,6 +354,14 @@ func (wd *wdaDriver) AppDeactivate(second float64) (err error) {
return
}
func (wd *wdaDriver) GetLastLaunchedApp() (packageName string) {
return wd.lastLaunchedPackageName
}
func (wd *wdaDriver) IsAppInForeground(packageName string) (bool, error) {
return false, errors.New("not implemented")
}
func (wd *wdaDriver) Tap(x, y int, options ...DataOption) error {
return wd.TapFloat(float64(x), float64(y), options...)
}

View File

@@ -93,16 +93,19 @@ func (s *veDEMOCRService) getOCRResult(imageBuf *bytes.Buffer) ([]OCRResult, err
// retry 3 times
for i := 1; i <= 3; i++ {
resp, err = client.Do(req)
if err == nil {
break
}
var logID string
if resp != nil {
logID = getLogID(resp.Header)
}
if err == nil && resp.StatusCode == http.StatusOK {
log.Debug().
Str("X-TT-LOGID", logID).
Int("imageBufSize", size).
Msg("request OCR service success")
break
}
log.Error().Err(err).
Str("logID", logID).
Str("X-TT-LOGID", logID).
Int("imageBufSize", size).
Msgf("request OCR service failed, retry %d", i)
time.Sleep(1 * time.Second)
@@ -172,14 +175,6 @@ func (s *veDEMOCRService) GetTexts(imageBuf *bytes.Buffer, options ...DataOption
dataOptions := NewDataOptions(options...)
if dataOptions.ScreenShotFilename != "" {
path, err := saveScreenShot(imageBuf, dataOptions.ScreenShotFilename)
if err != nil {
return nil, errors.Wrap(err, "save screenshot failed")
}
log.Debug().Str("path", path).Msg("save screenshot")
}
for _, ocrResult := range ocrResults {
rect := image.Rectangle{
// ocrResult.Points 顺序:左上 -> 右上 -> 右下 -> 左下
@@ -310,8 +305,7 @@ type OCRService interface {
func (dExt *DriverExt) GetTextsByOCR(options ...DataOption) (texts OCRTexts, err error) {
var bufSource *bytes.Buffer
if bufSource, err = dExt.takeScreenShot(); err != nil {
err = fmt.Errorf("takeScreenShot error: %v", err)
if bufSource, err = dExt.TakeScreenShot(builtin.GenNameWithTimestamp("step_%d_ocr")); err != nil {
return
}
@@ -326,8 +320,7 @@ func (dExt *DriverExt) GetTextsByOCR(options ...DataOption) (texts OCRTexts, err
func (dExt *DriverExt) FindTextByOCR(ocrText string, options ...DataOption) (x, y, width, height float64, err error) {
var bufSource *bytes.Buffer
if bufSource, err = dExt.takeScreenShot(); err != nil {
err = fmt.Errorf("takeScreenShot error: %v", err)
if bufSource, err = dExt.TakeScreenShot(builtin.GenNameWithTimestamp("step_%d_ocr")); err != nil {
return
}
@@ -345,8 +338,7 @@ func (dExt *DriverExt) FindTextByOCR(ocrText string, options ...DataOption) (x,
func (dExt *DriverExt) FindTextsByOCR(ocrTexts []string, options ...DataOption) (points [][]float64, err error) {
var bufSource *bytes.Buffer
if bufSource, err = dExt.takeScreenShot(); err != nil {
err = fmt.Errorf("takeScreenShot error: %v", err)
if bufSource, err = dExt.TakeScreenShot(builtin.GenNameWithTimestamp("step_%d_ocr")); err != nil {
return
}

View File

@@ -14,6 +14,8 @@ import (
"github.com/pkg/errors"
"gocv.io/x/gocv"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
)
const (
@@ -101,7 +103,7 @@ func (dExt *DriverExt) FindAllImageRect(search string) (rects []image.Rectangle,
if bufSearch, err = getBufFromDisk(search); err != nil {
return nil, err
}
if bufSource, err = dExt.takeScreenShot(); err != nil {
if bufSource, err = dExt.TakeScreenShot(builtin.GenNameWithTimestamp("step_%d_cv")); err != nil {
return nil, err
}
@@ -116,7 +118,7 @@ func (dExt *DriverExt) FindImageRectInUIKit(imagePath string, options ...DataOpt
if bufSearch, err = getBufFromDisk(imagePath); err != nil {
return 0, 0, 0, 0, err
}
if bufSource, err = dExt.takeScreenShot(); err != nil {
if bufSource, err = dExt.TakeScreenShot(builtin.GenNameWithTimestamp("step_%d_cv")); err != nil {
return 0, 0, 0, 0, err
}

View File

@@ -29,7 +29,10 @@ const (
const projectInfoFile = "proj.json" // used for ensuring root project
var pluginMap = sync.Map{} // used for reusing plugin instance
var (
pluginMap sync.Map // used for reusing plugin instance
pluginMutex sync.RWMutex
)
func initPlugin(path, venv string, logOn bool) (plugin funplugin.IPlugin, err error) {
// plugin file not found
@@ -42,6 +45,9 @@ func initPlugin(path, venv string, logOn bool) (plugin funplugin.IPlugin, err er
return nil, nil
}
pluginMutex.Lock()
defer pluginMutex.Unlock()
// reuse plugin instance if it already initialized
if p, ok := pluginMap.Load(pluginPath); ok {
return p.(funplugin.IPlugin), nil

View File

@@ -386,7 +386,7 @@ func (r *CaseRunner) parseConfig() error {
r.parsedConfig.WebSocketSetting.checkWebSocket()
// parse testcase config parameters
parametersIterator, err := initParametersIterator(r.parsedConfig)
parametersIterator, err := r.parser.initParametersIterator(r.parsedConfig)
if err != nil {
log.Error().Err(err).
Interface("parameters", r.parsedConfig.Parameters).
@@ -461,6 +461,7 @@ type SessionRunner struct {
startTime time.Time // record start time of the testcase
summary *TestCaseSummary // record test case summary
wsConnMap map[string]*websocket.Conn // save all websocket connections
inheritWsConnMap map[string]*websocket.Conn // inherit all websocket connections
pongResponseChan chan string // channel used to receive pong response message
closeResponseChan chan *wsCloseRespObject // channel used to receive close response message
}
@@ -472,22 +473,36 @@ func (r *SessionRunner) resetSession() {
r.startTime = time.Now()
r.summary = newSummary()
r.wsConnMap = make(map[string]*websocket.Conn)
r.inheritWsConnMap = make(map[string]*websocket.Conn)
r.pongResponseChan = make(chan string, 1)
r.closeResponseChan = make(chan *wsCloseRespObject, 1)
}
func (r *SessionRunner) inheritConnection(src *SessionRunner) {
log.Info().Msg("inherit session runner")
r.inheritWsConnMap = make(map[string]*websocket.Conn, len(src.wsConnMap)+len(src.inheritWsConnMap))
for k, v := range src.wsConnMap {
r.inheritWsConnMap[k] = v
}
for k, v := range src.inheritWsConnMap {
r.inheritWsConnMap[k] = v
}
}
// Start runs the test steps in sequential order.
// givenVars is used for data driven
func (r *SessionRunner) Start(givenVars map[string]interface{}) error {
config := r.caseRunner.testCase.Config
log.Info().Str("testcase", config.Name).Msg("run testcase start")
// reset session runner
r.resetSession()
// update config variables with given variables
r.InitWithParameters(givenVars)
defer func() {
// close session resource after all steps done or fast fail
r.releaseResources()
}()
// run step in sequential order
for _, step := range r.caseRunner.testCase.TestSteps {
// TODO: parse step struct
@@ -524,17 +539,7 @@ func (r *SessionRunner) Start(givenVars map[string]interface{}) error {
stepResult, err = step.Run(r)
stepResult.Name = stepName + loopIndex
// update summary
r.summary.Records = append(r.summary.Records, stepResult)
}
r.summary.Stat.Total += 1
if stepResult.Success {
r.summary.Stat.Successes += 1
} else {
r.summary.Stat.Failures += 1
// update summary result to failed
r.summary.Success = false
r.updateSummary(stepResult)
}
// update extracted variables
@@ -559,23 +564,10 @@ func (r *SessionRunner) Start(givenVars map[string]interface{}) error {
// check if failfast
if r.caseRunner.hrpRunner.failfast {
return errors.New("abort running due to failfast setting")
return errors.Wrap(err, "abort running due to failfast setting")
}
}
// close websocket connection after all steps done
defer func() {
for _, wsConn := range r.wsConnMap {
if wsConn != nil {
log.Info().Str("testcase", config.Name).Msg("websocket disconnected")
err := wsConn.Close()
if err != nil {
log.Error().Err(err).Msg("websocket disconnection failed")
}
}
}
}()
log.Info().Str("testcase", config.Name).Msg("run testcase end")
return nil
}
@@ -625,13 +617,16 @@ func (r *SessionRunner) GetSummary() (*TestCaseSummary, error) {
for uuid, client := range r.caseRunner.hrpRunner.uiClients {
// add WDA/UIA logs to summary
log, err := client.Driver.StopCaptureLog()
if err != nil {
return caseSummary, err
}
logs := map[string]interface{}{
"uuid": uuid,
"content": log,
"uuid": uuid,
}
if client.Device.LogEnabled() {
log, err := client.Driver.StopCaptureLog()
if err != nil {
return caseSummary, err
}
logs["content"] = log
}
// stop performance monitor
@@ -643,3 +638,59 @@ func (r *SessionRunner) GetSummary() (*TestCaseSummary, error) {
return caseSummary, nil
}
// updateSummary updates summary of StepResult.
func (r *SessionRunner) updateSummary(stepResult *StepResult) {
switch stepResult.StepType {
case stepTypeTestCase:
// record requests of testcase step
if records, ok := stepResult.Data.([]*StepResult); ok {
for _, result := range records {
r.addSingleStepResult(result)
}
} else {
r.addSingleStepResult(stepResult)
}
default:
r.addSingleStepResult(stepResult)
}
}
func (r *SessionRunner) addSingleStepResult(stepResult *StepResult) {
// update summary
r.summary.Records = append(r.summary.Records, stepResult)
r.summary.Stat.Total += 1
if stepResult.Success {
r.summary.Stat.Successes += 1
} else {
r.summary.Stat.Failures += 1
// update summary result to failed
r.summary.Success = false
}
}
// releaseResources releases resources used by session runner
func (r *SessionRunner) releaseResources() {
// close websocket connections
for _, wsConn := range r.wsConnMap {
if wsConn != nil {
log.Info().Str("testcase", r.caseRunner.testCase.Config.Name).Msg("websocket disconnected")
err := wsConn.Close()
if err != nil {
log.Error().Err(err).Msg("websocket disconnection failed")
}
}
}
}
func (r *SessionRunner) getWsClient(url string) *websocket.Conn {
if client, ok := r.wsConnMap[url]; ok {
return client
}
if client, ok := r.inheritWsConnMap[url]; ok {
return client
}
return nil
}

View File

@@ -63,7 +63,7 @@ func assertRunTestCases(t *testing.T) {
refCase := TestCasePath(demoTestCaseWithPluginJSONPath)
testcase1 := &TestCase{
Config: NewConfig("TestCase1").
SetBaseURL("http://httpbin.org"),
SetBaseURL("https://httpbin.org"),
TestSteps: []IStep{
NewStep("testcase1-step1").
GET("/headers").
@@ -77,7 +77,7 @@ func assertRunTestCases(t *testing.T) {
AssertEqual("headers.\"Content-Type\"", "application/json", "check http response Content-Type"),
NewStep("testcase1-step3").CallRefCase(
&TestCase{
Config: NewConfig("testcase1-step3-ref-case").SetBaseURL("http://httpbin.org"),
Config: NewConfig("testcase1-step3-ref-case").SetBaseURL("https://httpbin.org"),
TestSteps: []IStep{
NewStep("ip").
GET("/ip").

View File

@@ -2,11 +2,11 @@ package hrp
import (
"fmt"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
"github.com/httprunner/httprunner/v4/hrp/internal/code"
"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
)
@@ -281,6 +281,18 @@ func (s *StepMobile) Sleep(n float64) *StepMobile {
return &StepMobile{step: s.step}
}
// SleepRandom specify random sleeping seconds after last action
// params have two different kinds:
// 1. [min, max] : min and max are float64 time range boudaries
// 2. [min1, max1, weight1, min2, max2, weight2, ...] : weight is the probability of the time range
func (s *StepMobile) SleepRandom(params ...float64) *StepMobile {
s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
Method: uixt.CtlSleepRandom,
Params: params,
})
return &StepMobile{step: s.step}
}
func (s *StepMobile) ScreenShot() *StepMobile {
s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
Method: uixt.CtlScreenShot,
@@ -456,6 +468,36 @@ func (s *StepMobileUIValidation) AssertImageNotExists(expectedImagePath string,
return s
}
func (s *StepMobileUIValidation) AssertAppInForeground(packageName string, msg ...string) *StepMobileUIValidation {
v := Validator{
Check: uixt.SelectorForegroundApp,
Assert: uixt.AssertionEqual,
Expect: packageName,
}
if len(msg) > 0 {
v.Message = msg[0]
} else {
v.Message = fmt.Sprintf("app [%s] should be in foreground", packageName)
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepMobileUIValidation) AssertAppNotInForeground(packageName string, msg ...string) *StepMobileUIValidation {
v := Validator{
Check: uixt.SelectorForegroundApp,
Assert: uixt.AssertionNotEqual,
Expect: packageName,
}
if len(msg) > 0 {
v.Message = msg[0]
} else {
v.Message = fmt.Sprintf("app [%s] should not be in foreground", packageName)
}
s.step.Validators = append(s.step.Validators, v)
return s
}
func (s *StepMobileUIValidation) Name() string {
return s.step.Name
}
@@ -530,7 +572,6 @@ func runStepMobileUI(s *SessionRunner, step *TStep) (stepResult *StepResult, err
Success: false,
ContentSize: 0,
}
screenshots := make([]string, 0)
// merge step variables with session variables
stepVariables, err := s.ParseStepVariables(step.Variables)
@@ -549,11 +590,25 @@ func runStepMobileUI(s *SessionRunner, step *TStep) (stepResult *StepResult, err
attachments := make(map[string]interface{})
if err != nil {
attachments["error"] = err.Error()
// check if app is in foreground
packageName := uiDriver.Driver.GetLastLaunchedApp()
yes, err2 := uiDriver.Driver.IsAppInForeground(packageName)
if packageName != "" && (!yes || err2 != nil) {
log.Error().Err(err2).Str("packageName", packageName).Msg("app is not in foreground")
err = errors.Wrap(code.MobileUIAppNotInForegroundError, err.Error())
}
}
// take screenshot after each step
_, err := uiDriver.TakeScreenShot(
builtin.GenNameWithTimestamp("step_%d_") + step.Name)
if err != nil {
log.Error().Err(err).Str("step", step.Name).Msg("take screenshot failed on step finished")
}
// save attachments
screenshots = append(screenshots, uiDriver.ScreenShots...)
attachments["screenshots"] = screenshots
attachments["screenshots"] = uiDriver.GetScreenShots()
stepResult.Attachments = attachments
}()
@@ -587,16 +642,6 @@ func runStepMobileUI(s *SessionRunner, step *TStep) (stepResult *StepResult, err
}
}
// take snapshot
screenshotPath, err := uiDriver.ScreenShot(
fmt.Sprintf("validate_%d", time.Now().Unix()))
if err != nil {
log.Warn().Err(err).Str("step", step.Name).Msg("take screenshot failed")
} else {
log.Info().Str("path", screenshotPath).Msg("take screenshot before validation")
screenshots = append(screenshots, screenshotPath)
}
// validate
validateResults, err := validateUI(uiDriver, step.Validators)
if err != nil {

View File

@@ -187,6 +187,9 @@ func (r *requestBuilder) prepareUrlParams(stepVariables map[string]interface{})
r.req.URL = u
r.req.Host = u.Host
// update url
r.requestMap["url"] = u.String()
return nil
}
@@ -337,13 +340,34 @@ func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err
// add request object to step variables, could be used in setup hooks
stepVariables["hrp_step_name"] = step.Name
stepVariables["hrp_step_request"] = rb.requestMap
stepVariables["request"] = rb.requestMap
// deal with setup hooks
for _, setupHook := range step.SetupHooks {
_, err = parser.Parse(setupHook, stepVariables)
req, err := parser.Parse(setupHook, stepVariables)
if err != nil {
return stepResult, errors.Wrap(err, "run setup hooks failed")
}
reqMap, ok := req.(map[string]interface{})
if ok && reqMap != nil {
rb.requestMap = reqMap
stepVariables["request"] = reqMap
}
}
if len(step.SetupHooks) > 0 {
requestBody, ok := rb.requestMap["body"].(map[string]interface{})
if ok {
body, err := json.Marshal(requestBody)
if err == nil {
rb.req.Body = io.NopCloser(bytes.NewReader(body))
rb.req.ContentLength = int64(len(body))
}
}
headers, ok := rb.requestMap["headers"].(map[string]string)
rb.req.Header = map[string][]string{}
for key, value := range headers {
rb.req.Header.Set(key, value)
}
}
// log & print request
@@ -414,13 +438,19 @@ func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err
// add response object to step variables, could be used in teardown hooks
stepVariables["hrp_step_response"] = respObj.respObjMeta
stepVariables["response"] = respObj.respObjMeta
// deal with teardown hooks
for _, teardownHook := range step.TeardownHooks {
_, err = parser.Parse(teardownHook, stepVariables)
res, err := parser.Parse(teardownHook, stepVariables)
if err != nil {
return stepResult, errors.Wrap(err, "run teardown hooks failed")
}
resMpa, ok := res.(map[string]interface{})
if ok {
stepVariables["response"] = resMpa
respObj.respObjMeta = resMpa
}
}
sessionData.ReqResps.Request = rb.requestMap

View File

@@ -153,7 +153,7 @@ func TestRunRequestStatOn(t *testing.T) {
if !assert.Greater(t, stat["Total"], int64(1)) {
t.Fatal()
}
if !assert.Less(t, stat["Total"]-summary.Records[0].Elapsed, int64(3)) {
if !assert.Less(t, stat["Total"]-summary.Records[0].Elapsed, int64(100)) {
t.Fatal()
}
}
@@ -164,8 +164,8 @@ func TestRunCaseWithTimeout(t *testing.T) {
// global timeout
testcase1 := &TestCase{
Config: NewConfig("TestCase1").
SetTimeout(2 * time.Second). // set global timeout to 2s
SetBaseURL("http://httpbin.org"),
SetTimeout(10 * time.Second). // set global timeout to 10s
SetBaseURL("https://httpbin.org"),
TestSteps: []IStep{
NewStep("step1").
GET("/delay/1").
@@ -180,11 +180,11 @@ func TestRunCaseWithTimeout(t *testing.T) {
testcase2 := &TestCase{
Config: NewConfig("TestCase2").
SetTimeout(2 * time.Second). // set global timeout to 2s
SetBaseURL("http://httpbin.org"),
SetTimeout(10 * time.Second). // set global timeout to 10s
SetBaseURL("https://httpbin.org"),
TestSteps: []IStep{
NewStep("step1").
GET("/delay/3").
GET("/delay/11").
Validate().
AssertEqual("status_code", 200, "check status code"),
},
@@ -197,12 +197,12 @@ func TestRunCaseWithTimeout(t *testing.T) {
// step timeout
testcase3 := &TestCase{
Config: NewConfig("TestCase3").
SetTimeout(2 * time.Second).
SetBaseURL("http://httpbin.org"),
SetTimeout(10 * time.Second).
SetBaseURL("https://httpbin.org"),
TestSteps: []IStep{
NewStep("step2").
GET("/delay/3").
SetTimeout(4*time.Second). // set step timeout to 4s
GET("/delay/11").
SetTimeout(15*time.Second). // set step timeout to 4s
Validate().
AssertEqual("status_code", 200, "check status code"),
},

View File

@@ -89,6 +89,8 @@ func (s *StepTestCaseWithOptionalArgs) Run(r *SessionRunner) (stepResult *StepRe
return stepResult, err
}
sessionRunner := caseRunner.NewSession()
// need to inherit some information from current session
sessionRunner.inheritConnection(r)
start := time.Now()
// run referenced testcase with step variables

View File

@@ -314,7 +314,7 @@ func runStepWebSocket(r *SessionRunner, step *TStep) (stepResult *StepResult, er
case wsOpen:
log.Info().Int64("timeout(ms)", step.WebSocket.GetTimeout()).Str("url", parsedURL).Msg("open websocket connection")
// use the current websocket connection if existed
if r.wsConnMap[parsedURL] != nil {
if r.getWsClient(parsedURL) != nil {
break
}
resp, err = openWithTimeout(parsedURL, parsedHeader, r, step)
@@ -476,10 +476,15 @@ func openWithTimeout(urlStr string, requestHeader http.Header, r *SessionRunner,
conn.SetCloseHandler(func(code int, text string) error {
message := websocket.FormatCloseMessage(code, "")
conn.WriteControl(websocket.CloseMessage, message, time.Now().Add(defaultWriteWait))
r.closeResponseChan <- &wsCloseRespObject{
select {
case r.closeResponseChan <- &wsCloseRespObject{
StatusCode: code,
Text: text,
}:
default:
log.Warn().Msg("close response channel is block, drop the response")
}
return nil
})
r.wsConnMap[urlStr] = conn
@@ -499,7 +504,7 @@ func openWithTimeout(urlStr string, requestHeader http.Header, r *SessionRunner,
}
func readMessageWithTimeout(urlString string, r *SessionRunner, step *TStep) (*wsReadRespObject, error) {
wsConn := r.wsConnMap[urlString]
wsConn := r.getWsClient(urlString)
if wsConn == nil {
return nil, errors.New("try to use existing connection, but there is no connection")
}
@@ -529,7 +534,7 @@ func readMessageWithTimeout(urlString string, r *SessionRunner, step *TStep) (*w
}
func writeWebSocket(urlString string, r *SessionRunner, step *TStep, stepVariables map[string]interface{}) error {
wsConn := r.wsConnMap[urlString]
wsConn := r.getWsClient(urlString)
if wsConn == nil {
return errors.New("try to use existing connection, but there is no connection")
}
@@ -595,7 +600,7 @@ func writeWithAction(c *websocket.Conn, step *TStep, messageType int, message []
}
func closeWithTimeout(urlString string, r *SessionRunner, step *TStep, stepVariables map[string]interface{}) (*wsCloseRespObject, error) {
wsConn := r.wsConnMap[urlString]
wsConn := r.getWsClient(urlString)
if wsConn == nil {
return nil, errors.New("no connection needs to be closed")
}

View File

@@ -8,10 +8,10 @@ class TestHttpSession(unittest.TestCase):
self.session = HttpSession()
def test_request_http(self):
self.session.request("get", "http://httpbin.org/get")
self.session.request("get", "https://httpbin.org/get")
address = self.session.data.address
self.assertGreater(len(address.server_ip), 0)
self.assertEqual(address.server_port, 80)
self.assertEqual(address.server_port, 443)
self.assertGreater(len(address.client_ip), 0)
self.assertGreater(address.client_port, 10000)
@@ -26,7 +26,7 @@ class TestHttpSession(unittest.TestCase):
def test_request_http_allow_redirects(self):
self.session.request(
"get",
"http://httpbin.org/redirect-to?url=https%3A%2F%2Fgithub.com",
"https://httpbin.org/redirect-to?url=https%3A%2F%2Fgithub.com",
allow_redirects=True,
)
address = self.session.data.address
@@ -50,7 +50,7 @@ class TestHttpSession(unittest.TestCase):
def test_request_http_not_allow_redirects(self):
self.session.request(
"get",
"http://httpbin.org/redirect-to?url=https%3A%2F%2Fgithub.com",
"https://httpbin.org/redirect-to?url=https%3A%2F%2Fgithub.com",
allow_redirects=False,
)
address = self.session.data.address

View File

@@ -10,7 +10,7 @@ Then you can write upload test script as below:
- test:
name: upload file
request:
url: http://httpbin.org/upload
url: https://httpbin.org/upload
method: POST
headers:
Cookie: session=AAA-BBB-CCC
@@ -31,7 +31,7 @@ For compatibility, you can also write upload test script in old way:
field2: "value2"
m_encoder: ${multipart_encoder(file=$file, field1=$field1, field2=$field2)}
request:
url: http://httpbin.org/upload
url: https://httpbin.org/upload
method: POST
headers:
Content-Type: ${multipart_content_type($m_encoder)}
@@ -75,7 +75,9 @@ def ensure_upload_ready():
sys.exit(1)
def prepare_upload_step(step: TStep, step_variables: VariablesMapping, functions: FunctionsMapping):
def prepare_upload_step(
step: TStep, step_variables: VariablesMapping, functions: FunctionsMapping
):
"""preprocess for upload test
replace `upload` info with MultipartEncoder
@@ -84,7 +86,7 @@ def prepare_upload_step(step: TStep, step_variables: VariablesMapping, functions
{
"variables": {},
"request": {
"url": "http://httpbin.org/upload",
"url": "https://httpbin.org/upload",
"method": "POST",
"headers": {
"Cookie": "session=AAA-BBB-CCC"

115
poetry.lock generated
View File

@@ -56,10 +56,10 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "sphinx", "sphinx-notfound-page", "zope.interface"]
docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"]
tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"]
[package.source]
type = "legacy"
@@ -109,11 +109,11 @@ reference = "tsinghua"
[[package]]
name = "certifi"
version = "2021.10.8"
version = "2022.12.7"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = "*"
python-versions = ">=3.6"
[package.source]
type = "legacy"
@@ -129,7 +129,7 @@ optional = false
python-versions = ">=3.5.0"
[package.extras]
unicode_backport = ["unicodedata2"]
unicode-backport = ["unicodedata2"]
[package.source]
type = "legacy"
@@ -214,7 +214,7 @@ optional = true
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*"
[package.extras]
docs = ["sphinx"]
docs = ["Sphinx"]
[package.source]
type = "legacy"
@@ -247,9 +247,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
zipp = ">=0.5"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"]
perf = ["ipython"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
[package.source]
type = "legacy"
@@ -314,7 +314,7 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
dev = ["codecov (>=2.0.15)", "colorama (>=0.3.4)", "flake8 (>=3.7.7)", "isort (>=4.3.20)", "tox (>=3.9.0)", "tox-travis (>=0.12)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "Sphinx (>=2.2.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "black (>=19.3b0)"]
dev = ["Sphinx (>=2.2.1)", "black (>=19.3b0)", "codecov (>=2.0.15)", "colorama (>=0.3.4)", "flake8 (>=3.7.7)", "isort (>=4.3.20)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "tox (>=3.9.0)", "tox-travis (>=0.12)"]
[package.source]
type = "legacy"
@@ -385,8 +385,8 @@ optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"]
test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"]
[package.source]
type = "legacy"
@@ -485,7 +485,7 @@ optional = false
python-versions = ">=3.6.8"
[package.extras]
diagrams = ["railroad-diagrams", "jinja2"]
diagrams = ["jinja2", "railroad-diagrams"]
[package.source]
type = "legacy"
@@ -581,7 +581,7 @@ urllib3 = ">=1.21.1,<1.27"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<5)"]
[package.source]
type = "legacy"
@@ -623,7 +623,7 @@ bottle = ["bottle (>=0.12.13)"]
celery = ["celery (>=3)"]
django = ["django (>=1.8)"]
falcon = ["falcon (>=1.4)"]
flask = ["flask (>=0.11)", "blinker (>=1.1)"]
flask = ["blinker (>=1.1)", "flask (>=0.11)"]
pyspark = ["pyspark (>=2.4.4)"]
rq = ["rq (>=0.6)"]
sanic = ["sanic (>=0.8)"]
@@ -661,25 +661,25 @@ greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platfo
importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
[package.extras]
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
aiomysql = ["aiomysql", "greenlet (!=0.4.17)"]
aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"]
asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"]
mariadb_connector = ["mariadb (>=1.0.1)"]
asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"]
mariadb-connector = ["mariadb (>=1.0.1)"]
mssql = ["pyodbc"]
mssql_pymssql = ["pymssql"]
mssql_pyodbc = ["pyodbc"]
mypy = ["sqlalchemy2-stubs", "mypy (>=0.910)"]
mysql = ["mysqlclient (>=1.4.0,<2)", "mysqlclient (>=1.4.0)"]
mysql_connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"]
mssql-pymssql = ["pymssql"]
mssql-pyodbc = ["pyodbc"]
mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"]
mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"]
mysql-connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=7)", "cx_oracle (>=7,<8)"]
postgresql = ["psycopg2 (>=2.7)"]
postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"]
postgresql_pg8000 = ["pg8000 (>=1.16.6)"]
postgresql_psycopg2binary = ["psycopg2-binary"]
postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql (<1)", "pymysql"]
sqlcipher = ["sqlcipher3-binary"]
postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
postgresql-pg8000 = ["pg8000 (>=1.16.6)"]
postgresql-psycopg2binary = ["psycopg2-binary"]
postgresql-psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql", "pymysql (<1)"]
sqlcipher = ["sqlcipher3_binary"]
[package.source]
type = "legacy"
@@ -719,7 +719,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
ply = ">=3.4,<4.0"
[package.extras]
dev = ["cython (>=0.28.4)", "flake8 (>=2.5)", "pytest (>=2.8)", "sphinx-rtd-theme (>=0.1.9)", "sphinx (>=1.3)", "tornado (>=4.0,<6.0)"]
dev = ["cython (>=0.28.4)", "flake8 (>=2.5)", "pytest (>=2.8)", "sphinx (>=1.3)", "sphinx-rtd-theme (>=0.1.9)", "tornado (>=4.0,<6.0)"]
tornado = ["tornado (>=4.0,<6.0)"]
[package.source]
@@ -788,8 +788,8 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras]
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"]
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[package.source]
@@ -806,7 +806,7 @@ optional = false
python-versions = ">=3.5"
[package.extras]
dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
[package.source]
type = "legacy"
@@ -822,8 +822,8 @@ optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"]
testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
[package.source]
type = "legacy"
@@ -895,8 +895,21 @@ brotli = [
{file = "Brotli-1.0.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ee83d3e3a024a9618e5be64648d6d11c37047ac48adff25f12fa4226cf23d1c"},
{file = "Brotli-1.0.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:19598ecddd8a212aedb1ffa15763dd52a388518c4550e615aed88dc3753c0f0c"},
{file = "Brotli-1.0.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:44bb8ff420c1d19d91d79d8c3574b8954288bdff0273bf788954064d260d7ab0"},
{file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e23281b9a08ec338469268f98f194658abfb13658ee98e2b7f85ee9dd06caa91"},
{file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3496fc835370da351d37cada4cf744039616a6db7d13c430035e901443a34daa"},
{file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83bb06a0192cccf1eb8d0a28672a1b79c74c3a8a5f2619625aeb6f28b3a82bb"},
{file = "Brotli-1.0.9-cp310-cp310-win32.whl", hash = "sha256:26d168aac4aaec9a4394221240e8a5436b5634adc3cd1cdf637f6645cecbf181"},
{file = "Brotli-1.0.9-cp310-cp310-win_amd64.whl", hash = "sha256:622a231b08899c864eb87e85f81c75e7b9ce05b001e59bbfbf43d4a71f5f32b2"},
{file = "Brotli-1.0.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cc0283a406774f465fb45ec7efb66857c09ffefbe49ec20b7882eff6d3c86d3a"},
{file = "Brotli-1.0.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:11d3283d89af7033236fa4e73ec2cbe743d4f6a81d41bd234f24bf63dde979df"},
{file = "Brotli-1.0.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c1306004d49b84bd0c4f90457c6f57ad109f5cc6067a9664e12b7b79a9948ad"},
{file = "Brotli-1.0.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1375b5d17d6145c798661b67e4ae9d5496920d9265e2f00f1c2c0b5ae91fbde"},
{file = "Brotli-1.0.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cab1b5964b39607a66adbba01f1c12df2e55ac36c81ec6ed44f2fca44178bf1a"},
{file = "Brotli-1.0.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8ed6a5b3d23ecc00ea02e1ed8e0ff9a08f4fc87a1f58a2530e71c0f48adf882f"},
{file = "Brotli-1.0.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cb02ed34557afde2d2da68194d12f5719ee96cfb2eacc886352cb73e3808fc5d"},
{file = "Brotli-1.0.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b3523f51818e8f16599613edddb1ff924eeb4b53ab7e7197f85cbc321cdca32f"},
{file = "Brotli-1.0.9-cp311-cp311-win32.whl", hash = "sha256:ba72d37e2a924717990f4d7482e8ac88e2ef43fb95491eb6e0d124d77d2a150d"},
{file = "Brotli-1.0.9-cp311-cp311-win_amd64.whl", hash = "sha256:3ffaadcaeafe9d30a7e4e1e97ad727e4f5610b9fa2f7551998471e3736738679"},
{file = "Brotli-1.0.9-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4"},
{file = "Brotli-1.0.9-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296"},
{file = "Brotli-1.0.9-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:68715970f16b6e92c574c30747c95cf8cf62804569647386ff032195dc89a430"},
@@ -906,12 +919,18 @@ brotli = [
{file = "Brotli-1.0.9-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4"},
{file = "Brotli-1.0.9-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a"},
{file = "Brotli-1.0.9-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87fdccbb6bb589095f413b1e05734ba492c962b4a45a13ff3408fa44ffe6479b"},
{file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:6d847b14f7ea89f6ad3c9e3901d1bc4835f6b390a9c71df999b0162d9bb1e20f"},
{file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:495ba7e49c2db22b046a53b469bbecea802efce200dffb69b93dd47397edc9b6"},
{file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:4688c1e42968ba52e57d8670ad2306fe92e0169c6f3af0089be75bbac0c64a3b"},
{file = "Brotli-1.0.9-cp36-cp36m-win32.whl", hash = "sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14"},
{file = "Brotli-1.0.9-cp36-cp36m-win_amd64.whl", hash = "sha256:1c48472a6ba3b113452355b9af0a60da5c2ae60477f8feda8346f8fd48e3e87c"},
{file = "Brotli-1.0.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3b78a24b5fd13c03ee2b7b86290ed20efdc95da75a3557cc06811764d5ad1126"},
{file = "Brotli-1.0.9-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:9d12cf2851759b8de8ca5fde36a59c08210a97ffca0eb94c532ce7b17c6a3d1d"},
{file = "Brotli-1.0.9-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12"},
{file = "Brotli-1.0.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29d1d350178e5225397e28ea1b7aca3648fcbab546d20e7475805437bfb0a130"},
{file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7bbff90b63328013e1e8cb50650ae0b9bac54ffb4be6104378490193cd60f85a"},
{file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ec1947eabbaf8e0531e8e899fc1d9876c179fc518989461f5d24e2223395a9e3"},
{file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12effe280b8ebfd389022aa65114e30407540ccb89b177d3fbc9a4f177c4bd5d"},
{file = "Brotli-1.0.9-cp37-cp37m-win32.whl", hash = "sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1"},
{file = "Brotli-1.0.9-cp37-cp37m-win_amd64.whl", hash = "sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5"},
{file = "Brotli-1.0.9-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e16eb9541f3dd1a3e92b89005e37b1257b157b7256df0e36bd7b33b50be73bcb"},
@@ -919,6 +938,9 @@ brotli = [
{file = "Brotli-1.0.9-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb"},
{file = "Brotli-1.0.9-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26"},
{file = "Brotli-1.0.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a674ac10e0a87b683f4fa2b6fa41090edfd686a6524bd8dedbd6138b309175c"},
{file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e2d9e1cbc1b25e22000328702b014227737756f4b5bf5c485ac1d8091ada078b"},
{file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b336c5e9cf03c7be40c47b5fd694c43c9f1358a80ba384a21969e0b4e66a9b17"},
{file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:85f7912459c67eaab2fb854ed2bc1cc25772b300545fe7ed2dc03954da638649"},
{file = "Brotli-1.0.9-cp38-cp38-win32.whl", hash = "sha256:35a3edbe18e876e596553c4007a087f8bcfd538f19bc116917b3c7522fca0429"},
{file = "Brotli-1.0.9-cp38-cp38-win_amd64.whl", hash = "sha256:269a5743a393c65db46a7bb982644c67ecba4b8d91b392403ad8a861ba6f495f"},
{file = "Brotli-1.0.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2aad0e0baa04517741c9bb5b07586c642302e5fb3e75319cb62087bd0995ab19"},
@@ -926,15 +948,28 @@ brotli = [
{file = "Brotli-1.0.9-cp39-cp39-manylinux1_i686.whl", hash = "sha256:16d528a45c2e1909c2798f27f7bf0a3feec1dc9e50948e738b961618e38b6a7b"},
{file = "Brotli-1.0.9-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:56d027eace784738457437df7331965473f2c0da2c70e1a1f6fdbae5402e0389"},
{file = "Brotli-1.0.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bf919756d25e4114ace16a8ce91eb340eb57a08e2c6950c3cebcbe3dff2a5e7"},
{file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e4c4e92c14a57c9bd4cb4be678c25369bf7a092d55fd0866f759e425b9660806"},
{file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e48f4234f2469ed012a98f4b7874e7f7e173c167bed4934912a29e03167cf6b1"},
{file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9ed4c92a0665002ff8ea852353aeb60d9141eb04109e88928026d3c8a9e5433c"},
{file = "Brotli-1.0.9-cp39-cp39-win32.whl", hash = "sha256:cfc391f4429ee0a9370aa93d812a52e1fee0f37a81861f4fdd1f4fb28e8547c3"},
{file = "Brotli-1.0.9-cp39-cp39-win_amd64.whl", hash = "sha256:854c33dad5ba0fbd6ab69185fec8dab89e13cda6b7d191ba111987df74f38761"},
{file = "Brotli-1.0.9-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9749a124280a0ada4187a6cfd1ffd35c350fb3af79c706589d98e088c5044267"},
{file = "Brotli-1.0.9-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:73fd30d4ce0ea48010564ccee1a26bfe39323fde05cb34b5863455629db61dc7"},
{file = "Brotli-1.0.9-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:02177603aaca36e1fd21b091cb742bb3b305a569e2402f1ca38af471777fb019"},
{file = "Brotli-1.0.9-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:76ffebb907bec09ff511bb3acc077695e2c32bc2142819491579a695f77ffd4d"},
{file = "Brotli-1.0.9-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b43775532a5904bc938f9c15b77c613cb6ad6fb30990f3b0afaea82797a402d8"},
{file = "Brotli-1.0.9-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5bf37a08493232fbb0f8229f1824b366c2fc1d02d64e7e918af40acd15f3e337"},
{file = "Brotli-1.0.9-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:330e3f10cd01da535c70d09c4283ba2df5fb78e915bea0a28becad6e2ac010be"},
{file = "Brotli-1.0.9-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e1abbeef02962596548382e393f56e4c94acd286bd0c5afba756cffc33670e8a"},
{file = "Brotli-1.0.9-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3148362937217b7072cf80a2dcc007f09bb5ecb96dae4617316638194113d5be"},
{file = "Brotli-1.0.9-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:336b40348269f9b91268378de5ff44dc6fbaa2268194f85177b53463d313842a"},
{file = "Brotli-1.0.9-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b09a16a1950b9ef495a0f8b9d0a87599a9d1f179e2d4ac014b2ec831f87e7"},
{file = "Brotli-1.0.9-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c8e521a0ce7cf690ca84b8cc2272ddaf9d8a50294fd086da67e517439614c755"},
{file = "Brotli-1.0.9.zip", hash = "sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438"},
]
certifi = [
{file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
{file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
{file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"},
{file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"},
]
charset-normalizer = [
{file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},