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"},