diff --git a/README.en.md b/README.en.md
index 0a30ce22..e242b461 100644
--- a/README.en.md
+++ b/README.en.md
@@ -118,6 +118,12 @@ Use "hrp [command] --help" for more information about a command.
+## Sponsor
+
+[
](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 的微信公众号,第一时间获得最新资讯。
diff --git a/README.md b/README.md
index 2ca5dc81..b16276ac 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,12 @@ Use "hrp [command] --help" for more information about a command.
+## 赞助商
+
+[
](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 的微信公众号,第一时间获得最新资讯。
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index cdf53f67..524aca34 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -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**
diff --git a/docs/assets/hogwarts.jpeg b/docs/assets/hogwarts.jpeg
deleted file mode 100644
index 78105f91..00000000
Binary files a/docs/assets/hogwarts.jpeg and /dev/null differ
diff --git a/docs/assets/hogwarts.png b/docs/assets/hogwarts.png
new file mode 100644
index 00000000..ae14fa7f
Binary files /dev/null and b/docs/assets/hogwarts.png differ
diff --git a/examples/data/a_b_c/T1_test.py b/examples/data/a_b_c/T1_test.py
index 6caf90b1..5adf2afd 100644
--- a/examples/data/a_b_c/T1_test.py
+++ b/examples/data/a_b_c/T1_test.py
@@ -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
diff --git a/examples/data/a_b_c/T2_3_test.py b/examples/data/a_b_c/T2_3_test.py
index 3a4bb701..4411bed1 100644
--- a/examples/data/a_b_c/T2_3_test.py
+++ b/examples/data/a_b_c/T2_3_test.py
@@ -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
diff --git a/examples/httpbin/validate.yml b/examples/httpbin/validate.yml
index d5769a7b..f18f14e9 100644
--- a/examples/httpbin/validate.yml
+++ b/examples/httpbin/validate.yml
@@ -1,6 +1,6 @@
config:
name: basic test with httpbin
- base_url: http://httpbin.org/
+ base_url: https://httpbin.org/
teststeps:
-
diff --git a/examples/httpbin/validate_test.py b/examples/httpbin/validate_test.py
index 18f38d2e..44d5a4d2 100644
--- a/examples/httpbin/validate_test.py
+++ b/examples/httpbin/validate_test.py
@@ -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(
diff --git a/examples/postman_echo/request_methods/hardcode_test.py b/examples/postman_echo/request_methods/hardcode_test.py
index ba0702a4..a74f4aaa 100644
--- a/examples/postman_echo/request_methods/hardcode_test.py
+++ b/examples/postman_echo/request_methods/hardcode_test.py
@@ -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
diff --git a/examples/postman_echo/request_methods/request_with_functions_test.py b/examples/postman_echo/request_methods/request_with_functions_test.py
index 54afbef1..cba5888f 100644
--- a/examples/postman_echo/request_methods/request_with_functions_test.py
+++ b/examples/postman_echo/request_methods/request_with_functions_test.py
@@ -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
diff --git a/examples/postman_echo/request_methods/request_with_parameters_test.py b/examples/postman_echo/request_methods/request_with_parameters_test.py
index 120f8d58..0755d95e 100644
--- a/examples/postman_echo/request_methods/request_with_parameters_test.py
+++ b/examples/postman_echo/request_methods/request_with_parameters_test.py
@@ -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
diff --git a/examples/postman_echo/request_methods/request_with_testcase_reference_test.py b/examples/postman_echo/request_methods/request_with_testcase_reference_test.py
index 34c406e1..19aebb1d 100644
--- a/examples/postman_echo/request_methods/request_with_testcase_reference_test.py
+++ b/examples/postman_echo/request_methods/request_with_testcase_reference_test.py
@@ -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
diff --git a/examples/postman_echo/request_methods/request_with_variables_test.py b/examples/postman_echo/request_methods/request_with_variables_test.py
index 9834cb4d..89aaef48 100644
--- a/examples/postman_echo/request_methods/request_with_variables_test.py
+++ b/examples/postman_echo/request_methods/request_with_variables_test.py
@@ -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
diff --git a/examples/postman_echo/request_methods/validate_with_functions_test.py b/examples/postman_echo/request_methods/validate_with_functions_test.py
index fa906fe4..d75de2fe 100644
--- a/examples/postman_echo/request_methods/validate_with_functions_test.py
+++ b/examples/postman_echo/request_methods/validate_with_functions_test.py
@@ -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
diff --git a/examples/postman_echo/request_methods/validate_with_variables_test.py b/examples/postman_echo/request_methods/validate_with_variables_test.py
index 275be39f..aa06a855 100644
--- a/examples/postman_echo/request_methods/validate_with_variables_test.py
+++ b/examples/postman_echo/request_methods/validate_with_variables_test.py
@@ -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
diff --git a/examples/uitest/demo_android_douyin_test.go b/examples/uitest/demo_android_douyin_test.go
deleted file mode 100644
index 7d7c94f4..00000000
--- a/examples/uitest/demo_android_douyin_test.go
+++ /dev/null
@@ -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)
- }
-}
diff --git a/examples/uitest/demo_android_feed_swipe.json b/examples/uitest/demo_android_feed_swipe.json
new file mode 100644
index 00000000..d74f45ae
--- /dev/null
+++ b/examples/uitest/demo_android_feed_swipe.json
@@ -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-5s,30% 随机间隔 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"
+ }
+ ]
+ }
+ ]
+}
diff --git a/examples/uitest/demo_android_feed_swipe_test.go b/examples/uitest/demo_android_feed_swipe_test.go
new file mode 100644
index 00000000..c2a024c3
--- /dev/null
+++ b/examples/uitest/demo_android_feed_swipe_test.go
@@ -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-5s,30% 随机间隔 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)
+ }
+}
diff --git a/examples/uitest/demo_android_live_swipe.json b/examples/uitest/demo_android_live_swipe.json
new file mode 100644
index 00000000..3912f1ca
--- /dev/null
+++ b/examples/uitest/demo_android_live_swipe.json
@@ -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-5s,40% 随机间隔 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"
+ }
+ ]
+ }
+ ]
+}
diff --git a/examples/uitest/demo_android_live_swipe_test.go b/examples/uitest/demo_android_live_swipe_test.go
new file mode 100644
index 00000000..500aff1d
--- /dev/null
+++ b/examples/uitest/demo_android_live_swipe_test.go
@@ -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-5s,40% 随机间隔 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)
+ }
+}
diff --git a/examples/uitest/demo_douyin_live.json b/examples/uitest/demo_ios_live_swipe.json
similarity index 100%
rename from examples/uitest/demo_douyin_live.json
rename to examples/uitest/demo_ios_live_swipe.json
diff --git a/examples/uitest/demo_douyin_test.go b/examples/uitest/demo_ios_live_swipe_test.go
similarity index 91%
rename from examples/uitest/demo_douyin_test.go
rename to examples/uitest/demo_ios_live_swipe_test.go
index def2e9d3..575edc58 100644
--- a/examples/uitest/demo_douyin_test.go
+++ b/examples/uitest/demo_ios_live_swipe_test.go
@@ -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)
}
diff --git a/examples/worldcup/main.go b/examples/worldcup/main.go
index e67f024c..697bcd57 100644
--- a/examples/worldcup/main.go
+++ b/examples/worldcup/main.go
@@ -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
diff --git a/go.mod b/go.mod
index df99d58c..f6975946 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index b941d7b5..979a82b0 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/hrp/boomer.go b/hrp/boomer.go
index 4e3ecef5..0f35e34d 100644
--- a/hrp/boomer.go
+++ b/hrp/boomer.go
@@ -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
diff --git a/hrp/boomer_test.go b/hrp/boomer_test.go
index 83151b5e..9eadc91a 100644
--- a/hrp/boomer_test.go
+++ b/hrp/boomer_test.go
@@ -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").
diff --git a/hrp/cmd/adb/devices.go b/hrp/cmd/adb/devices.go
index 86c93608..3a0de351 100644
--- a/hrp/cmd/adb/devices.go
+++ b/hrp/cmd/adb/devices.go
@@ -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 {
diff --git a/hrp/cmd/adb/init.go b/hrp/cmd/adb/init.go
index 9025ef70..e2a86bce 100644
--- a/hrp/cmd/adb/init.go
+++ b/hrp/cmd/adb/init.go
@@ -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)
}
diff --git a/hrp/cmd/adb/screencap.go b/hrp/cmd/adb/screencap.go
new file mode 100644
index 00000000..8147e0c4
--- /dev/null
+++ b/hrp/cmd/adb/screencap.go
@@ -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)
+}
diff --git a/hrp/cmd/ios/devices.go b/hrp/cmd/ios/devices.go
index c1f230d3..ad8fb55c 100644
--- a/hrp/cmd/ios/devices.go
+++ b/hrp/cmd/ios/devices.go
@@ -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 {
diff --git a/hrp/cmd/ios/init.go b/hrp/cmd/ios/init.go
index 209846fb..8ec31071 100644
--- a/hrp/cmd/ios/init.go
+++ b/hrp/cmd/ios/init.go
@@ -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
}
diff --git a/hrp/internal/builtin/function.go b/hrp/internal/builtin/function.go
index 00d01d97..baf94a19 100644
--- a/hrp/internal/builtin/function.go
+++ b/hrp/internal/builtin/function.go
@@ -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)
}
diff --git a/hrp/internal/builtin/utils.go b/hrp/internal/builtin/utils.go
index 63dbfd0f..b88f7902 100644
--- a/hrp/internal/builtin/utils.go
+++ b/hrp/internal/builtin/utils.go
@@ -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())
+}
diff --git a/hrp/internal/code/code.go b/hrp/internal/code/code.go
index 4687bc96..3479c8b6 100644
--- a/hrp/internal/code/code.go
+++ b/hrp/internal/code/code.go
@@ -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,
diff --git a/hrp/internal/version/VERSION b/hrp/internal/version/VERSION
index b0136b0c..c294f2b5 100644
--- a/hrp/internal/version/VERSION
+++ b/hrp/internal/version/VERSION
@@ -1 +1 @@
-v4.3.2
\ No newline at end of file
+v4.3.3
\ No newline at end of file
diff --git a/hrp/parameters.go b/hrp/parameters.go
index af54afa0..69a35904 100644
--- a/hrp/parameters.go
+++ b/hrp/parameters.go
@@ -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()).
diff --git a/hrp/parameters_test.go b/hrp/parameters_test.go
index 02176415..4d2781f2 100644
--- a/hrp/parameters_test.go
+++ b/hrp/parameters_test.go
@@ -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()
}
diff --git a/hrp/pkg/gadb/README.md b/hrp/pkg/gadb/README.md
index dc67b810..2027cea5 100644
--- a/hrp/pkg/gadb/README.md
+++ b/hrp/pkg/gadb/README.md
@@ -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
diff --git a/hrp/pkg/gadb/client.go b/hrp/pkg/gadb/client.go
index 4543484a..bc0349ff 100644
--- a/hrp/pkg/gadb/client.go
+++ b/hrp/pkg/gadb/client.go
@@ -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
diff --git a/hrp/pkg/gadb/client_test.go b/hrp/pkg/gadb/client_test.go
index 404af6a8..56dd1a94 100644
--- a/hrp/pkg/gadb/client_test.go
+++ b/hrp/pkg/gadb/client_test.go
@@ -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)
+}
diff --git a/hrp/pkg/gadb/device.go b/hrp/pkg/gadb/device.go
index 59c4d1e6..2067755a 100644
--- a/hrp/pkg/gadb/device.go
+++ b/hrp/pkg/gadb/device.go
@@ -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
+}
diff --git a/hrp/pkg/gadb/device_test.go b/hrp/pkg/gadb/device_test.go
index 70ae4889..29f48b4d 100644
--- a/hrp/pkg/gadb/device_test.go
+++ b/hrp/pkg/gadb/device_test.go
@@ -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())
+}
diff --git a/hrp/pkg/gadb/features.go b/hrp/pkg/gadb/features.go
new file mode 100644
index 00000000..806f175c
--- /dev/null
+++ b/hrp/pkg/gadb/features.go
@@ -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
+}
diff --git a/hrp/pkg/gadb/sync_transport.go b/hrp/pkg/gadb/sync_transport.go
index 6e55df6b..ff7346a4 100644
--- a/hrp/pkg/gadb/sync_transport.go
+++ b/hrp/pkg/gadb/sync_transport.go
@@ -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()
}
diff --git a/hrp/pkg/gadb/transport.go b/hrp/pkg/gadb/transport.go
index ae900429..c450d053 100644
--- a/hrp/pkg/gadb/transport.go
+++ b/hrp/pkg/gadb/transport.go
@@ -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()
}
diff --git a/hrp/pkg/gadb/transport_test.go b/hrp/pkg/gadb/transport_test.go
index 143cd438..2610a19e 100644
--- a/hrp/pkg/gadb/transport_test.go
+++ b/hrp/pkg/gadb/transport_test.go
@@ -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)
diff --git a/hrp/pkg/gadb/utils.go b/hrp/pkg/gadb/utils.go
new file mode 100644
index 00000000..e6536c37
--- /dev/null
+++ b/hrp/pkg/gadb/utils.go
@@ -0,0 +1,9 @@
+package gadb
+
+import (
+ "net"
+)
+
+func DisableTimeWait(conn *net.TCPConn) error {
+ return conn.SetLinger(0)
+}
diff --git a/hrp/pkg/uixt/android_adb_driver.go b/hrp/pkg/uixt/android_adb_driver.go
index 5c80e1ef..68f8c28b 100644
--- a/hrp/pkg/uixt/android_adb_driver.go
+++ b/hrp/pkg/uixt/android_adb_driver.go
@@ -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 -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) {
// 强制停止应用,停止 相关的进程
// adb shell am force-stop
- _, 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
+}
diff --git a/hrp/pkg/uixt/android_device.go b/hrp/pkg/uixt/android_device.go
index 0df9162d..b511659d 100644
--- a/hrp/pkg/uixt/android_device.go
+++ b/hrp/pkg/uixt/android_device.go
@@ -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{}{}
}()
diff --git a/hrp/pkg/uixt/android_test.go b/hrp/pkg/uixt/android_test.go
index 44d8d3ac..b2a89037 100644
--- a/hrp/pkg/uixt/android_test.go
+++ b/hrp/pkg/uixt/android_test.go
@@ -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)
diff --git a/hrp/pkg/uixt/client.go b/hrp/pkg/uixt/client.go
index 5ebd9309..95a8b277 100644
--- a/hrp/pkg/uixt/client.go
+++ b/hrp/pkg/uixt/client.go
@@ -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 {
diff --git a/hrp/pkg/uixt/ext.go b/hrp/pkg/uixt/ext.go
index d87e7b18..ca09aaed 100644
--- a/hrp/pkg/uixt/ext.go
+++ b/hrp/pkg/uixt/ext.go
@@ -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 {
diff --git a/hrp/pkg/uixt/interface.go b/hrp/pkg/uixt/interface.go
index 865ed9fb..7d6145b3 100644
--- a/hrp/pkg/uixt/interface.go
+++ b/hrp/pkg/uixt/interface.go
@@ -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
diff --git a/hrp/pkg/uixt/ios_device.go b/hrp/pkg/uixt/ios_device.go
index da8a9a33..93828a8d 100644
--- a/hrp/pkg/uixt/ios_device.go
+++ b/hrp/pkg/uixt/ios_device.go
@@ -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 {
diff --git a/hrp/pkg/uixt/ios_driver.go b/hrp/pkg/uixt/ios_driver.go
index 53339d8c..33bbe0ed 100644
--- a/hrp/pkg/uixt/ios_driver.go
+++ b/hrp/pkg/uixt/ios_driver.go
@@ -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...)
}
diff --git a/hrp/pkg/uixt/ocr_vedem.go b/hrp/pkg/uixt/ocr_vedem.go
index 03d8a271..15db742d 100644
--- a/hrp/pkg/uixt/ocr_vedem.go
+++ b/hrp/pkg/uixt/ocr_vedem.go
@@ -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
}
diff --git a/hrp/pkg/uixt/opencv.go b/hrp/pkg/uixt/opencv.go
index 6a5b3bb8..98f7286b 100644
--- a/hrp/pkg/uixt/opencv.go
+++ b/hrp/pkg/uixt/opencv.go
@@ -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
}
diff --git a/hrp/plugin.go b/hrp/plugin.go
index 850d17c4..a23c7dd2 100644
--- a/hrp/plugin.go
+++ b/hrp/plugin.go
@@ -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
diff --git a/hrp/runner.go b/hrp/runner.go
index 34a50a7b..2c5e3c06 100644
--- a/hrp/runner.go
+++ b/hrp/runner.go
@@ -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
+}
diff --git a/hrp/runner_test.go b/hrp/runner_test.go
index c07cf1f1..9a0280bb 100644
--- a/hrp/runner_test.go
+++ b/hrp/runner_test.go
@@ -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").
diff --git a/hrp/step_mobile_ui.go b/hrp/step_mobile_ui.go
index 5d37b203..ff8b3a2c 100644
--- a/hrp/step_mobile_ui.go
+++ b/hrp/step_mobile_ui.go
@@ -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 {
diff --git a/hrp/step_request.go b/hrp/step_request.go
index 12a8ffbb..faa0016a 100644
--- a/hrp/step_request.go
+++ b/hrp/step_request.go
@@ -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
diff --git a/hrp/step_request_test.go b/hrp/step_request_test.go
index 3f7e63c5..7172bf66 100644
--- a/hrp/step_request_test.go
+++ b/hrp/step_request_test.go
@@ -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"),
},
diff --git a/hrp/step_testcase.go b/hrp/step_testcase.go
index 6cfdeeaa..c72fe9e8 100644
--- a/hrp/step_testcase.go
+++ b/hrp/step_testcase.go
@@ -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
diff --git a/hrp/step_websocket.go b/hrp/step_websocket.go
index fbb5cfd5..ec633ca7 100644
--- a/hrp/step_websocket.go
+++ b/hrp/step_websocket.go
@@ -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")
}
diff --git a/httprunner/client_test.py b/httprunner/client_test.py
index 467d4246..9fd32ca8 100644
--- a/httprunner/client_test.py
+++ b/httprunner/client_test.py
@@ -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
diff --git a/httprunner/ext/uploader/__init__.py b/httprunner/ext/uploader/__init__.py
index 6bbd86cf..a89fce79 100644
--- a/httprunner/ext/uploader/__init__.py
+++ b/httprunner/ext/uploader/__init__.py
@@ -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"
diff --git a/poetry.lock b/poetry.lock
index bf7305fb..ee1c4dd6 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -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"},