Repository: httprunner/httprunner (language: python)

Issue:
feat: get ocr position by given recognition area



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/hrp-scaffold.yml b/.github/workflows/hrp-scaffold.yml
index e513435a6..2d0ab07d1 100644
--- a/.github/workflows/hrp-scaffold.yml
+++ b/.github/workflows/hrp-scaffold.yml
@@ -17,7 +17,7 @@ jobs:
       fail-fast: false
       matrix:
         go-version:
-          - 1.17.x
+          - 1.18.x
         os: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.os }}
     steps:
@@ -44,7 +44,7 @@ jobs:
       fail-fast: false
       matrix:
         go-version:
-          - 1.17.x
+          - 1.18.x
         os: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.os }}
     steps:
@@ -76,7 +76,7 @@ jobs:
       fail-fast: false
       matrix:
         go-version:
-          - 1.17.x
+          - 1.18.x
         os: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.os }}
     steps:
diff --git a/.gitignore b/.gitignore
index 0de9fa232..6d2852b5b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@ logs
 reports
 *.xml
 htmlcov/
+screenshots/
 
 # built plugins
 debugtalk.bin
diff --git a/Makefile b/Makefile
index b094ea9c9..f2f15434d 100644
--- a/Makefile
+++ b/Makefile
@@ -16,7 +16,7 @@ bump: ## bump hrp version, e.g. make bump version=4.0.0
 .PHONY: build
 build: ## build hrp cli tool
 	@echo "[info] build hrp cli tool"
-	@. scripts/build.sh
+	@. scripts/build.sh $(tags)
 
 .PHONY: install-hooks
 install-hooks: ## install git hooks
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 802fbe46c..f3aaf35c5 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Release History
 
+## v4.3.0 (2022-10-21)
+
+- feat: support iOS UI automation with [WebDriverAgent]
+- feat support Android UI automation with [uiautomator2]
+- feat: integrage ios device management with [gidevice]
+- feat: add specified exit code for different exceptions
+- refactor: make boomer/uixt/httpstat as sub package
+
 ## v4.2.1 (2022-09-01)
 
 **go version**
@@ -669,4 +677,7 @@ reference: [v2-changelog]
 [locust]: https://locust.io/
 [black]: https://github.com/psf/black
 [loguru]: https://github.com/Delgan/loguru
-[v2-changelog]: https://github.com/httprunner/httprunner/blob/v2/docs/CHANGELOG.md
\ No newline at end of file
+[v2-changelog]: https://github.com/httprunner/httprunner/blob/v2/docs/CHANGELOG.md
+[WebDriverAgent]: https://github.com/appium/WebDriverAgent
+[uiautomator2]: https://github.com/appium/appium-uiautomator2-server
+[gidevice]: https://github.com/electricbubble/gidevice
diff --git a/docs/cmd/hrp.md b/docs/cmd/hrp.md
index fae4a0042..e60bfc4ce 100644
--- a/docs/cmd/hrp.md
+++ b/docs/cmd/hrp.md
@@ -41,4 +41,4 @@ Copyright 2017 debugtalk
 * [hrp traceroute](hrp_traceroute.md)	 - run integrated traceroute command
 * [hrp wiki](hrp_wiki.md)	 - visit https://httprunner.com
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_boom.md b/docs/cmd/hrp_boom.md
index 20adfde08..c5c92782f 100644
--- a/docs/cmd/hrp_boom.md
+++ b/docs/cmd/hrp_boom.md
@@ -44,6 +44,7 @@ hrp boom [flags]
       --profile string                  profile for load testing
       --prometheus-gateway string       Prometheus Pushgateway url.
       --request-increase-rate string    Request increase rate, disabled by default. (default "-1")
+      --run-time int                    Stop after the specified amount of time(s), Only used  --autostart. Defaults to run forever.
       --spawn-count int                 The number of users to spawn for load testing (default 1)
       --spawn-rate float                The rate for spawning users (default 1)
       --worker                          worker of distributed testing
@@ -54,4 +55,4 @@ hrp boom [flags]
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 * [hrp boom curl](hrp_boom_curl.md)	 - run load test with curl command
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_boom_curl.md b/docs/cmd/hrp_boom_curl.md
index 87a0a66f3..f8c7dcc54 100644
--- a/docs/cmd/hrp_boom_curl.md
+++ b/docs/cmd/hrp_boom_curl.md
@@ -16,4 +16,4 @@ hrp boom curl URLs [flags]
 
 * [hrp boom](hrp_boom.md)	 - run load test with boomer
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_build.md b/docs/cmd/hrp_build.md
index 0be74f06a..69f024ffa 100644
--- a/docs/cmd/hrp_build.md
+++ b/docs/cmd/hrp_build.md
@@ -28,4 +28,4 @@ hrp build $path ... [flags]
 
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_convert.md b/docs/cmd/hrp_convert.md
index 24b7ecd94..5f47069a3 100644
--- a/docs/cmd/hrp_convert.md
+++ b/docs/cmd/hrp_convert.md
@@ -23,4 +23,4 @@ hrp convert $path... [flags]
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 * [hrp convert curl](hrp_convert_curl.md)	 - convert curl command to httprunner testcase
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_convert_curl.md b/docs/cmd/hrp_convert_curl.md
index 9880780a2..c6c2dde3f 100644
--- a/docs/cmd/hrp_convert_curl.md
+++ b/docs/cmd/hrp_convert_curl.md
@@ -16,4 +16,4 @@ hrp convert curl URLs [flags]
 
 * [hrp convert](hrp_convert.md)	 - convert to JSON/YAML/gotest/pytest testcases
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_curl.md b/docs/cmd/hrp_curl.md
index 82fe75117..5ef6e71dd 100644
--- a/docs/cmd/hrp_curl.md
+++ b/docs/cmd/hrp_curl.md
@@ -16,4 +16,4 @@ hrp curl $url [flags]
 
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_dns.md b/docs/cmd/hrp_dns.md
index 57f051d3b..d2aaef604 100644
--- a/docs/cmd/hrp_dns.md
+++ b/docs/cmd/hrp_dns.md
@@ -26,4 +26,4 @@ hrp dns $url [flags]
 
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_ping.md b/docs/cmd/hrp_ping.md
index 164652ada..0475d93b1 100644
--- a/docs/cmd/hrp_ping.md
+++ b/docs/cmd/hrp_ping.md
@@ -20,4 +20,4 @@ hrp ping $url [flags]
 
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_run.md b/docs/cmd/hrp_run.md
index 7079fc39c..a0f70751a 100644
--- a/docs/cmd/hrp_run.md
+++ b/docs/cmd/hrp_run.md
@@ -36,4 +36,4 @@ hrp run $path... [flags]
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 * [hrp run curl](hrp_run_curl.md)	 - run API test with curl command
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_run_curl.md b/docs/cmd/hrp_run_curl.md
index cdc3f54c7..672a26c2a 100644
--- a/docs/cmd/hrp_run_curl.md
+++ b/docs/cmd/hrp_run_curl.md
@@ -16,4 +16,4 @@ hrp run curl URLs [flags]
 
 * [hrp run](hrp_run.md)	 - run API test with go engine
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_startproject.md b/docs/cmd/hrp_startproject.md
index d64f16dc2..888c74573 100644
--- a/docs/cmd/hrp_startproject.md
+++ b/docs/cmd/hrp_startproject.md
@@ -21,4 +21,4 @@ hrp startproject $project_name [flags]
 
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_traceroute.md b/docs/cmd/hrp_traceroute.md
index f959d8806..a31d1dd18 100644
--- a/docs/cmd/hrp_traceroute.md
+++ b/docs/cmd/hrp_traceroute.md
@@ -19,4 +19,4 @@ hrp traceroute $url [flags]
 
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/docs/cmd/hrp_wiki.md b/docs/cmd/hrp_wiki.md
index 56669a98f..97986ea5d 100644
--- a/docs/cmd/hrp_wiki.md
+++ b/docs/cmd/hrp_wiki.md
@@ -16,4 +16,4 @@ hrp wiki [flags]
 
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/examples/data/postman/__init__.py b/examples/data/postman/__init__.py
new file mode 100644
index 000000000..70cfba53f
--- /dev/null
+++ b/examples/data/postman/__init__.py
@@ -0,0 +1,1 @@
+# NOTICE: Generated By HttpRunner. DO NOT EDIT!
diff --git a/examples/demo-empty-project/proj.json b/examples/demo-empty-project/proj.json
index b2b376f64..edf454642 100644
--- a/examples/demo-empty-project/proj.json
+++ b/examples/demo-empty-project/proj.json
@@ -1,5 +1,5 @@
 {
     "project_name": "demo-empty-project",
-    "create_time": "2022-07-11T11:45:29.942532+08:00",
-    "hrp_version": "v4.1.6"
+    "create_time": "2022-10-21T21:54:56.252853+08:00",
+    "hrp_version": "v4.3.0"
 }
diff --git a/examples/demo-with-go-plugin/plugin/debugtalk_gen.go b/examples/demo-with-go-plugin/plugin/debugtalk_gen.go
deleted file mode 100644
index 0ee1ae227..000000000
--- a/examples/demo-with-go-plugin/plugin/debugtalk_gen.go
+++ /dev/null
@@ -1,16 +0,0 @@
-// NOTE: Generated By hrp v4.1.5, DO NOT EDIT!
-package main
-
-import (
-	"github.com/httprunner/funplugin/fungo"
-)
-
-func main() {
-	fungo.Register("SumTwoInt", SumTwoInt)
-	fungo.Register("SumInts", SumInts)
-	fungo.Register("Sum", Sum)
-	fungo.Register("SetupHookExample", SetupHookExample)
-	fungo.Register("TeardownHookExample", TeardownHookExample)
-	fungo.Register("GetUserAgent", GetUserAgent)
-	fungo.Serve()
-}
diff --git a/examples/demo-with-go-plugin/proj.json b/examples/demo-with-go-plugin/proj.json
index ecc3509d5..867531eb1 100644
--- a/examples/demo-with-go-plugin/proj.json
+++ b/examples/demo-with-go-plugin/proj.json
@@ -1,5 +1,5 @@
 {
     "project_name": "demo-with-go-plugin",
-    "create_time": "2022-07-26T10:30:29.31361+08:00",
-    "hrp_version": "v4.2.0"
+    "create_time": "2022-10-21T21:52:38.979867+08:00",
+    "hrp_version": "v4.3.0"
 }
diff --git a/examples/demo-with-py-plugin/.debugtalk_gen.py b/examples/demo-with-py-plugin/.debugtalk_gen.py
deleted file mode 100644
index 50f50e5fa..000000000
--- a/examples/demo-with-py-plugin/.debugtalk_gen.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# NOTE: Generated By hrp v4.1.6, DO NOT EDIT!
-
-import sys
-import os
-
-sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-
-from debugtalk import *
-
-
-if __name__ == "__main__":
-    import funppy
-    funppy.register("get_user_agent", get_user_agent)
-    funppy.register("sleep", sleep)
-    funppy.register("sum", sum)
-    funppy.register("sum_ints", sum_ints)
-    funppy.register("sum_two_int", sum_two_int)
-    funppy.register("sum_two_string", sum_two_string)
-    funppy.register("sum_strings", sum_strings)
-    funppy.register("concatenate", concatenate)
-    funppy.register("setup_hook_example", setup_hook_example)
-    funppy.register("teardown_hook_example", teardown_hook_example)
-    funppy.serve()
diff --git a/examples/demo-with-py-plugin/proj.json b/examples/demo-with-py-plugin/proj.json
index 8140ae8ba..c0aeb7760 100644
--- a/examples/demo-with-py-plugin/proj.json
+++ b/examples/demo-with-py-plugin/proj.json
@@ -1,5 +1,5 @@
 {
     "project_name": "demo-with-py-plugin",
-    "create_time": "2022-07-26T10:30:30.601095+08:00",
-    "hrp_version": "v4.2.0"
+    "create_time": "2022-10-21T21:52:39.555851+08:00",
+    "hrp_version": "v4.3.0"
 }
diff --git a/examples/demo-without-plugin/proj.json b/examples/demo-without-plugin/proj.json
index 50c061866..593129a33 100644
--- a/examples/demo-without-plugin/proj.json
+++ b/examples/demo-without-plugin/proj.json
@@ -1,5 +1,5 @@
 {
     "project_name": "demo-without-plugin",
-    "create_time": "2022-07-11T11:45:29.800018+08:00",
-    "hrp_version": "v4.1.6"
+    "create_time": "2022-10-21T21:54:56.136458+08:00",
+    "hrp_version": "v4.3.0"
 }
diff --git a/examples/postman_echo/.debugtalk_gen.py b/examples/postman_echo/.debugtalk_gen.py
new file mode 100644
index 000000000..f3e588777
--- /dev/null
+++ b/examples/postman_echo/.debugtalk_gen.py
@@ -0,0 +1,20 @@
+# NOTE: Generated By hrp v4.2.0, DO NOT EDIT!
+
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from debugtalk import *
+
+
+if __name__ == "__main__":
+    import funppy
+    funppy.register("get_httprunner_version", get_httprunner_version)
+    funppy.register("sum_two", sum_two)
+    funppy.register("get_testcase_config_variables", get_testcase_config_variables)
+    funppy.register("get_testsuite_config_variables", get_testsuite_config_variables)
+    funppy.register("get_app_version", get_app_version)
+    funppy.register("calculate_two_nums", calculate_two_nums)
+    funppy.register("fake_rand_count", fake_rand_count)
+    funppy.serve()
diff --git a/go.mod b/go.mod
index 32da26b0a..29a113b88 100644
--- a/go.mod
+++ b/go.mod
@@ -1,39 +1,91 @@
 module github.com/httprunner/httprunner/v4
 
-go 1.16
+go 1.18
 
 require (
 	github.com/andybalholm/brotli v1.0.4
 	github.com/denisbrodbeck/machineid v1.0.1
+	github.com/electricbubble/gadb v0.0.7
+	github.com/electricbubble/gidevice v0.6.2
+	github.com/electricbubble/opencv-helper v0.0.3
 	github.com/fatih/color v1.13.0
 	github.com/getsentry/sentry-go v0.13.0
-	github.com/go-errors/errors v1.0.1
-	github.com/go-openapi/spec v0.20.6
+	github.com/go-openapi/spec v0.20.7
 	github.com/go-ping/ping v1.1.0
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/google/uuid v1.3.0
-	github.com/gorilla/websocket v1.4.1
+	github.com/gorilla/websocket v1.5.0
 	github.com/httprunner/funplugin v0.5.0
-	github.com/jinzhu/copier v0.3.2
+	github.com/jinzhu/copier v0.3.5
 	github.com/jmespath/go-jmespath v0.4.0
 	github.com/json-iterator/go v1.1.12
 	github.com/maja42/goval v1.2.1
-	github.com/miekg/dns v1.1.25
-	github.com/mitchellh/mapstructure v1.4.1
+	github.com/miekg/dns v1.1.50
+	github.com/mitchellh/mapstructure v1.5.0
 	github.com/olekukonko/tablewriter v0.0.5
 	github.com/pkg/errors v0.9.1
-	github.com/prometheus/client_golang v1.11.0
-	github.com/rs/zerolog v1.26.1
+	github.com/prometheus/client_golang v1.13.0
+	github.com/rs/zerolog v1.28.0
 	github.com/shirou/gopsutil v3.21.11+incompatible
-	github.com/spf13/cobra v1.2.1
-	github.com/stretchr/testify v1.7.0
+	github.com/spf13/cobra v1.5.0
+	github.com/stretchr/testify v1.8.0
+	golang.org/x/net v0.0.0-20220919232410-f2f64ebce3c1
+	golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1
+	google.golang.org/grpc v1.49.0
+	google.golang.org/protobuf v1.28.1
+	gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+	cloud.google.com/go/compute v1.7.0 // indirect
+	github.com/beorn7/perks v1.0.1 // indirect
+	github.com/cespare/xxhash/v2 v2.1.2 // indirect
+	github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/go-errors/errors v1.4.2 // indirect
+	github.com/go-ole/go-ole v1.2.6 // indirect
+	github.com/go-openapi/jsonpointer v0.19.5 // indirect
+	github.com/go-openapi/jsonreference v0.20.0 // indirect
+	github.com/go-openapi/swag v0.22.3 // indirect
+	github.com/golang/protobuf v1.5.2 // indirect
+	github.com/hashicorp/go-hclog v1.3.0 // indirect
+	github.com/hashicorp/go-plugin v1.4.5 // indirect
+	github.com/hashicorp/yamux v0.1.1 // indirect
+	github.com/inconshreveable/mousetrap v1.0.1 // indirect
+	github.com/josharian/intern v1.0.0 // indirect
+	github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 // 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-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
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/oklog/run v1.1.0 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/prometheus/client_model v0.2.0 // indirect
+	github.com/prometheus/common v0.37.0 // indirect
+	github.com/prometheus/procfs v0.8.0 // indirect
+	github.com/rivo/uniseg v0.2.0 // indirect
+	github.com/russross/blackfriday/v2 v2.1.0 // indirect
+	github.com/satori/go.uuid v1.2.0 // indirect
+	github.com/spf13/pflag v1.0.5 // indirect
 	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/net v0.0.0-20220225172249-27dd8689420f
-	golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602
-	google.golang.org/grpc v1.45.0
-	google.golang.org/protobuf v1.28.0
-	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
+	gocv.io/x/gocv v0.31.0 // indirect
+	golang.org/x/mod v0.4.2 // 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
+	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
+	howett.net/plist v1.0.0 // indirect
 )
 
 // replace github.com/httprunner/funplugin => ../funplugin
+replace github.com/electricbubble/gidevice => github.com/debugtalk/gidevice v0.6.3-0.20221012071407-9b59e12ecc77
diff --git a/go.sum b/go.sum
index cdc0824ff..f9df4ccf2 100644
--- a/go.sum
+++ b/go.sum
@@ -17,17 +17,33 @@ cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKP
 cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
 cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
 cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
-cloud.google.com/go v0.81.0 h1:at8Tk2zUz63cLPR0JPWm5vp77pEZmzxEQBEfRKn1VV8=
 cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
+cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
+cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
+cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
+cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
+cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
+cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
+cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
+cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
+cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
+cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
 cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
 cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
+cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
+cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
+cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
+cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
+cloud.google.com/go/compute v1.7.0 h1:v/k9Eueb8aAJ0vZuxKMrgm6kPhCLZU9HxFU+AFDs9Uk=
+cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
+cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
 cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
@@ -37,15 +53,11 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
-github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
-github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
-github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
-github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
+github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -54,20 +66,15 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5
 github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
-github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
+github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -76,63 +83,56 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
 github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
+github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+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/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
-github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
-github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
-github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
-github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
-github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/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=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/debugtalk/gidevice v0.6.3-0.20221012071407-9b59e12ecc77 h1:wP/2aKW6YV0ityxp0Ecv8JDwA/cy6gayVhA/t+roO+w=
+github.com/debugtalk/gidevice v0.6.3-0.20221012071407-9b59e12ecc77/go.mod h1:bRHL2M9qgeEKju8KRvKMZUVEg7t5zMnTiG3SJ3QDH5o=
 github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
 github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
-github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
-github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
-github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
-github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
+github.com/electricbubble/gadb v0.0.7 h1:fxvVLVNs3IFKuYAEXDF2tDZUjT9jNCltoTSirjM5dgo=
+github.com/electricbubble/gadb v0.0.7/go.mod h1:3293YJ6OWHv/Q6NA5dwSbK43MbmYm8+Vz2d7h5J3IA8=
+github.com/electricbubble/opencv-helper v0.0.3 h1:p0sHTUPPPm8GqzVUtYH+wQbJoguzotUXVRAS7Ibk7nI=
+github.com/electricbubble/opencv-helper v0.0.3/go.mod h1:VHB21p5xsIjXUsUleWSaKGJosRsRAO7cuJoZKf7uCcc=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
 github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
+github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
-github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
-github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
 github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
 github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
-github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
-github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
-github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
-github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
 github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
+github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
 github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
-github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
+github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
 github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
 github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
 github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -140,25 +140,17 @@ github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUe
 github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
 github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
 github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
-github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=
-github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
+github.com/go-openapi/spec v0.20.7 h1:1Rlu/ZrOCCob0n+JKKJAWhNWMPW8bOZRg8FJaY+0SKI=
+github.com/go-openapi/spec v0.20.7/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
-github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
 github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
+github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
+github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
 github.com/go-ping/ping v1.1.0 h1:3MCGhVX4fyEUuhsfwPrsEdQw6xspHkv5zHsiSoDFZYw=
 github.com/go-ping/ping v1.1.0/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk=
-github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
-github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
-github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
-github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
-github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
-github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -171,6 +163,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
 github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
 github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -189,7 +182,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
 github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
 github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
+github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -202,13 +195,16 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
 github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -220,6 +216,9 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
@@ -227,56 +226,43 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
 github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
-github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
-github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
+github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
+github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
+github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
+github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
+github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
-github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
 github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-hclog v1.1.0 h1:QsGcniKx5/LuX2eYoeL+Np3UKYPNaN7YKpTh29h8rbw=
 github.com/hashicorp/go-hclog v1.1.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
-github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/go-plugin v1.4.3 h1:DXmvivbWD5qdiBts9TpBC7BYL1Aia5sxbRgQB+v6UZM=
+github.com/hashicorp/go-hclog v1.3.0 h1:G0ACM8Z2WilWgPv3Vdzwm3V0BQu/kSmrkVtpe1fy9do=
+github.com/hashicorp/go-hclog v1.3.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
 github.com/hashicorp/go-plugin v1.4.3/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ=
-github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
-github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
-github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
-github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
+github.com/hashicorp/go-plugin v1.4.5 h1:oTE/oQR4eghggRg8VY7PAz3dr++VwDNBGCcOfIvHpBo=
+github.com/hashicorp/go-plugin v1.4.5/go.mod h1:viDMjcLJuDui6pXb8U4HVfb8AamCWhHGUjr2IrTF67s=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
-github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
-github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
-github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
-github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb h1:b5rjCoWHc7eqmAS4/qyk21ZsHyb6Mxv/jykxvNTkU4M=
 github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
+github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
+github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
 github.com/httprunner/funplugin v0.5.0 h1:Laoe8URu71qeyST9wvRtGSkDWc8Y3T1IrnvFSTHmO84=
 github.com/httprunner/funplugin v0.5.0/go.mod h1:vPyeJIfbpGe0epZZtAV0wCn16gLY9+imSw/zfxq0Lcc=
+github.com/hybridgroup/mjpeg v0.0.0-20140228234708-4680f319790e/go.mod h1:eagM805MRKrioHYuU7iKLUyFPVKqVV6um5DAvCkUtXs=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
-github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
-github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk=
-github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g=
-github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
+github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
+github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
 github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE=
 github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74=
-github.com/jinzhu/copier v0.3.2 h1:QdBOCbaouLDYaIPFfi1bKv5F5tPpeTwXe4sD0jqtz5w=
-github.com/jinzhu/copier v0.3.2/go.mod h1:24xnZezI2Yqac9J61UC6/dG/k76ttpq0DdJI3QmUvro=
+github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg=
+github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
@@ -285,82 +271,56 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
 github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
 github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
 github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
-github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8=
-github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE=
-github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE=
-github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro=
-github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
-github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
-github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y=
-github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
-github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
-github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
+github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 h1:EnfXoSqDfSNJv0VBNqY/88RNnhSGYkrHaO0mmFGbVsc=
+github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40/go.mod h1:vy1vK6wD6j7xX6O6hXe621WabdtNkou2h7uRtTfRMyg=
 github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
 github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
-github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
 github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.com/maja42/goval v1.2.1 h1:fyEgzddqPgCZsKcFLk4C6SdCHyEaAHYvtZG4mGzQOHU=
 github.com/maja42/goval v1.2.1/go.mod h1:42LU+BQXL/veE9jnTTUOSj38GRmOTSThYSXRVodI5J4=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
 github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
-github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
-github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
 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 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
-github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
+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-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
+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=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8=
-github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
-github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
-github.com/miekg/dns v1.1.25 h1:dFwPR6SfLtrSwgDcIq2bcU/gVutB4sNApq2HBdqcakg=
-github.com/miekg/dns v1.1.25/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
-github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
-github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
+github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
 github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
-github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0=
-github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
-github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
-github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
-github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
-github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
+github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -368,37 +328,29 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
-github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
-github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
-github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
-github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
 github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
+github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
+github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
-github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
+github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
-github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
 github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
-github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ=
 github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
+github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
+github.com/prometheus/client_golang v1.13.0 h1:b71QUfeo5M8gq2+evJdTPfZhYMAU0uKPkyPJ7TPsloU=
+github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -406,80 +358,58 @@ github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2
 github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
 github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
-github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ=
 github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
+github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
+github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
 github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
-github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4=
 github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
+github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
-github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
+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/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
-github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
-github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
-github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
+github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
+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=
+github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
 github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
-github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
 github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
-github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
-github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw=
-github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
-github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
-github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
-github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
+github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
-github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/tklauser/go-sysconf v0.3.10 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw=
 github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk=
-github.com/tklauser/numcpus v0.4.0 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o=
 github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ=
-github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
-github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
-github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
-github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
-github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
-github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
-github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
-github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
-github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
-github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
+github.com/tklauser/numcpus v0.5.0 h1:ooe7gN0fg6myJ0EKoTAf5hebTZrH52px3New/D9iJ+A=
+github.com/tklauser/numcpus v0.5.0/go.mod h1:OGzpTxpcIMNGYQdit2BYL1pvk/dSOaJWjKoflh+RQjo=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -488,9 +418,6 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
 github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
 github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
-go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
-go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -499,24 +426,15 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
 go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
-go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
+gocv.io/x/gocv v0.27.0/go.mod h1:n4LnYjykU6y9gn48yZf4eLCdtuSb77XxSkW6g0wGf/A=
+gocv.io/x/gocv v0.31.0 h1:BHDtK8v+YPvoSPQTTiZB2fM/7BLg6511JqkruY2z6LQ=
+gocv.io/x/gocv v0.31.0/go.mod h1:oc6FvfYqfBp99p+yOEzs9tbYF9gOrAQSeL/dyIPefJU=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e h1:1SzTfNOXwIS2oWiMF+6qu0OUDKb0dauo6MoDUQyu+yU=
 golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -552,18 +470,15 @@ 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/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=
-golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -572,8 +487,6 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -596,10 +509,18 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+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/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=
@@ -611,8 +532,17 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 h1:0Ja1LBD+yisY6RWM/BH7TJVXWsSjs2VwBSmvSX4HdBc=
-golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
+golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 h1:lxqLZaMad/dJHMFZH0NiNpiEZI/nhgWhe4wgzpE+MuA=
+golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -623,14 +553,13 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220907140024-f12130a52804 h1:0SH2R3f1b1VmIMG7BXbEZCBUu2dKmHschSmjqGUrW8A=
+golang.org/x/sync v0.0.0-20220907140024-f12130a52804/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -640,14 +569,9 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -680,19 +604,36 @@ golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+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/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=
@@ -708,16 +649,12 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 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=
-golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
 golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@@ -725,10 +662,8 @@ golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgw
 golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
 golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -751,7 +686,6 @@ golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
@@ -760,15 +694,23 @@ golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4f
 golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
+golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+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/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 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 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=
 google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -790,7 +732,24 @@ google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34q
 google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
 google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
 google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
-google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
+google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
+google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
+google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
+google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
+google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
+google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
+google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
+google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
+google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
+google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
+google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
+google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
+google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
+google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
+google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
+google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
+google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
+google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -839,10 +798,48 @@ google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
 google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
+google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
 google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 h1:ErU+UA6wxadoU8nWrsy5MZUVBs75K17zUCsUCIfrXCE=
+google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
+google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
+google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
+google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
+google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
+google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
+google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
+google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
+google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
+google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
+google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51 h1:ucpgjuzWqWrj0NEwjUpsGTf2IGxyLtmuSk0oGgifjec=
+google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@@ -863,9 +860,21 @@ google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA5
 google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
+google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
 google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M=
+google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
+google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
+google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
+google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
+google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
+google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw=
+google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
+google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -879,18 +888,17 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
+google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
+gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -900,11 +908,11 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -912,6 +920,9 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+howett.net/plist v0.0.0-20201203080718-1454fab16a06/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0=
+howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
+howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/hrp/README.md b/hrp/README.md
index 38a413918..683a0e1ac 100644
--- a/hrp/README.md
+++ b/hrp/README.md
@@ -51,28 +51,50 @@ type HRPRunner struct {
 }
 
 func (r *HRPRunner) Run(testcases ...ITestCase) error
-func (r *HRPRunner) NewSessionRunner(testcase *TestCase) *SessionRunner
+func (r *HRPRunner) NewCaseRunner(testcase *TestCase) (*CaseRunner, error)
 ```
 
 重点关注两个方法：
 
 - Run：测试执行的主入口，支持运行一个或多个测试用例
-- NewSessionRunner：针对给定的测试用例初始化一个 SessionRunner
+- NewCaseRunner：针对给定的测试用例初始化一个 CaseRunner
 
-### 用例执行器 SessionRunner
+### 用例执行器 CaseRunner
 
-测试用例的具体执行都由 `SessionRunner` 完成，每个 TestCase 对应一个实例，在该实例中除了包含测试用例自身内容外，还会包含测试过程的 session 数据和最终测试结果 summary。
+针对每个测试用例，采用 CaseRunner 存储其公共信息，包括 plugin/parser
+
+```go
+type CaseRunner struct {
+	testCase  *TestCase
+	hrpRunner *HRPRunner
+	parser    *Parser
+
+	parsedConfig       *TConfig
+	parametersIterator *ParametersIterator
+	rootDir            string // project root dir
+}
+
+func (r *CaseRunner) NewSession() *SessionRunner {
+```
+
+重点关注一个方法：
+
+- NewSession：测试用例的每一次执行对应一个 SessionRunner
+
+### SessionRunner
+
+测试用例的具体执行都由 `SessionRunner` 完成，每个 session 实例中除了包含测试用例自身内容外，还会包含测试过程的 session 数据和最终测试结果 summary。
 
 ```go
 type SessionRunner struct {
-	testCase         *TestCase
-	hrpRunner        *HRPRunner
-	parser           *Parser
+	caseRunner       *CaseRunner
 	sessionVariables map[string]interface{}
-	transactions map[string]map[transactionType]time.Time
-	startTime    time.Time        // record start time of the testcase
-	summary      *TestCaseSummary // record test case summary
+	transactions      map[string]map[transactionType]time.Time
+	startTime         time.Time                  // record start time of the testcase
+	summary           *TestCaseSummary           // record test case summary
 }
+
+func (r *SessionRunner) Start(givenVars map[string]interface{}) error
 ```
 
 重点关注一个方法：
@@ -80,12 +102,29 @@ type SessionRunner struct {
 - Start：启动执行用例，依次执行所有测试步骤
 
 ```go
-func (r *SessionRunner) Start() error {
+func (r *SessionRunner) Start(givenVars map[string]interface{}) error {
 	...
+	r.resetSession()
+
+	r.InitWithParameters(givenVars)
+
 	// run step in sequential order
 	for _, step := range r.testCase.TestSteps {
-		_, err := step.Run(r)
-		if err != nil && r.hrpRunner.failfast {
+		// parse step
+
+		// run step
+		stepResult, err := step.Run(r)
+
+		// update summary
+		r.summary.Records = append(r.summary.Records, stepResult)
+
+		// update extracted variables
+		for k, v := range stepResult.ExportVars {
+			r.sessionVariables[k] = v
+		}
+
+		// check if failfast
+		if err != nil && r.caseRunner.hrpRunner.failfast {
 			return errors.Wrap(err, "abort running due to failfast setting")
 		}
 	}
diff --git a/hrp/boomer.go b/hrp/boomer.go
index e8ea55fc5..4e3ecef52 100644
--- a/hrp/boomer.go
+++ b/hrp/boomer.go
@@ -10,12 +10,14 @@ import (
 	"time"
 
 	"github.com/httprunner/funplugin"
-	"github.com/httprunner/httprunner/v4/hrp/internal/boomer"
+	"github.com/rs/zerolog/log"
+	"golang.org/x/net/context"
+
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
 	"github.com/httprunner/httprunner/v4/hrp/internal/json"
 	"github.com/httprunner/httprunner/v4/hrp/internal/sdk"
-	"github.com/rs/zerolog/log"
-	"golang.org/x/net/context"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/boomer"
 )
 
 func NewStandaloneBoomer(spawnCount int64, spawnRate float64) *HRPBoomer {
@@ -120,7 +122,7 @@ func (b *HRPBoomer) ConvertTestCasesToBoomerTasks(testcases ...ITestCase) (taskS
 	testCases, err := LoadTestCases(testcases...)
 	if err != nil {
 		log.Error().Err(err).Msg("failed to load testcases")
-		os.Exit(1)
+		os.Exit(code.GetErrorCode(err))
 	}
 
 	for _, testcase := range testCases {
@@ -135,10 +137,10 @@ func (b *HRPBoomer) ConvertTestCasesToBoomerTasks(testcases ...ITestCase) (taskS
 func (b *HRPBoomer) ParseTestCases(testCases []*TestCase) []*TCase {
 	var parsedTestCases []*TCase
 	for _, tc := range testCases {
-		caseRunner, err := b.hrpRunner.newCaseRunner(tc)
+		caseRunner, err := b.hrpRunner.NewCaseRunner(tc)
 		if err != nil {
 			log.Error().Err(err).Msg("failed to create runner")
-			os.Exit(1)
+			os.Exit(code.GetErrorCode(err))
 		}
 		caseRunner.parsedConfig.Parameters = caseRunner.parametersIterator.outParameters()
 		parsedTestCases = append(parsedTestCases, &TCase{
@@ -154,7 +156,7 @@ func (b *HRPBoomer) TestCasesToBytes(testcases ...ITestCase) []byte {
 	testCases, err := LoadTestCases(testcases...)
 	if err != nil {
 		log.Error().Err(err).Msg("failed to load testcases")
-		os.Exit(1)
+		os.Exit(code.GetErrorCode(err))
 	}
 	tcs := b.ParseTestCases(testCases)
 	testCasesBytes, err := json.Marshal(tcs)
@@ -252,7 +254,6 @@ func (b *HRPBoomer) rebalanceRunner(profile *boomer.Profile) {
 	log.Info().Interface("profile", profile).Msg("rebalance tasks successfully")
 }
 
-
 func (b *HRPBoomer) PollTasks(ctx context.Context) {
 	for {
 		select {
@@ -261,7 +262,7 @@ func (b *HRPBoomer) PollTasks(ctx context.Context) {
 			if len(b.Boomer.GetTasksChan()) > 0 {
 				continue
 			}
-			//Todo: 过滤掉已经传输过的task
+			// Todo: 过滤掉已经传输过的task
 			if task.TestCasesBytes != nil {
 				// init boomer with profile
 				b.initWorker(task.Profile)
@@ -313,12 +314,12 @@ func (b *HRPBoomer) PollTestCases(ctx context.Context) {
 }
 
 func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rendezvous) *boomer.Task {
-	// init runner for testcase
+	// init case runner for testcase
 	// this runner is shared by multiple session runners
-	caseRunner, err := b.hrpRunner.newCaseRunner(testcase)
+	caseRunner, err := b.hrpRunner.NewCaseRunner(testcase)
 	if err != nil {
 		log.Error().Err(err).Msg("failed to create runner")
-		os.Exit(1)
+		os.Exit(code.GetErrorCode(err))
 	}
 	if caseRunner.parser.plugin != nil {
 		b.pluginsMutex.Lock()
@@ -352,18 +353,19 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend
 			transactionSuccess := true // flag current transaction result
 
 			// init session runner
-			sessionRunner := caseRunner.newSession()
+			sessionRunner := caseRunner.NewSession()
 
 			mutex.Lock()
 			if parametersIterator.HasNext() {
-				sessionRunner.updateSessionVariables(parametersIterator.Next())
+				sessionRunner.InitWithParameters(parametersIterator.Next())
 			}
 			mutex.Unlock()
 
 			startTime := time.Now()
 			for _, step := range testcase.TestSteps {
+				// TODO: parse step struct
 				// parse step name
-				parsedName, err := sessionRunner.parser.ParseString(step.Name(), sessionRunner.sessionVariables)
+				parsedName, err := caseRunner.parser.ParseString(step.Name(), sessionRunner.sessionVariables)
 				if err != nil {
 					parsedName = step.Name()
 				}
@@ -382,7 +384,8 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend
 						if result.Success {
 							b.RecordSuccess(string(result.StepType), result.Name, result.Elapsed, result.ContentSize)
 						} else {
-							b.RecordFailure(string(result.StepType), result.Name, result.Elapsed, result.Attachment)
+							exception, _ := result.Attachments.(string)
+							b.RecordFailure(string(result.StepType), result.Name, result.Elapsed, exception)
 						}
 					}
 				}
diff --git a/hrp/build.go b/hrp/build.go
index 9cecccfb9..491bdb8fb 100644
--- a/hrp/build.go
+++ b/hrp/build.go
@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"html/template"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"regexp"
 	"strings"
@@ -16,6 +15,8 @@ import (
 	"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/internal/myexec"
 	"github.com/httprunner/httprunner/v4/hrp/internal/version"
 )
 
@@ -128,26 +129,26 @@ func (pt *pluginTemplate) generateGo(output string) error {
 	}
 
 	// check go sdk in tempDir
-	if err := builtin.ExecCommandInDir(exec.Command("go", "version"), pluginDir); err != nil {
+	if err := myexec.RunCommand("go", "version"); err != nil {
 		return errors.Wrap(err, "go sdk not installed")
 	}
 
 	if !builtin.IsFilePathExists(filepath.Join(pluginDir, "go.mod")) {
 		// create go mod
-		if err := builtin.ExecCommandInDir(exec.Command("go", "mod", "init", "main"), pluginDir); err != nil {
+		if err := myexec.ExecCommandInDir(myexec.Command("go", "mod", "init", "main"), pluginDir); err != nil {
 			return err
 		}
 
 		// download plugin dependency
 		// funplugin version should be locked
 		funplugin := fmt.Sprintf("github.com/httprunner/funplugin@%s", shared.Version)
-		if err := builtin.ExecCommandInDir(exec.Command("go", "get", funplugin), pluginDir); err != nil {
+		if err := myexec.ExecCommandInDir(myexec.Command("go", "get", funplugin), pluginDir); err != nil {
 			return errors.Wrap(err, "go get funplugin failed")
 		}
 	}
 
 	// add missing and remove unused modules
-	if err := builtin.ExecCommandInDir(exec.Command("go", "mod", "tidy"), pluginDir); err != nil {
+	if err := myexec.ExecCommandInDir(myexec.Command("go", "mod", "tidy"), pluginDir); err != nil {
 		return errors.Wrap(err, "go mod tidy failed")
 	}
 
@@ -161,8 +162,8 @@ func (pt *pluginTemplate) generateGo(output string) error {
 	outputPath, _ := filepath.Abs(output)
 
 	// build go plugin to debugtalk.bin
-	cmd := exec.Command("go", "build", "-o", outputPath, PluginGoSourceGenFile, filepath.Base(pt.path))
-	if err := builtin.ExecCommandInDir(cmd, pluginDir); err != nil {
+	cmd := myexec.Command("go", "build", "-o", outputPath, PluginGoSourceGenFile, filepath.Base(pt.path))
+	if err := myexec.ExecCommandInDir(cmd, pluginDir); err != nil {
 		return errors.Wrap(err, "go build plugin failed")
 	}
 	log.Info().Str("output", outputPath).Str("plugin", pt.path).Msg("build go plugin successfully")
@@ -175,11 +176,11 @@ func buildGo(path string, output string) error {
 	content, err := os.ReadFile(path)
 	if err != nil {
 		log.Error().Err(err).Msg("failed to read file")
-		return errors.Wrap(err, "read file failed")
+		return errors.Wrap(code.LoadFileError, err.Error())
 	}
 	functionNames, err := regexGoFunctionName.findAllFunctionNames(string(content))
 	if err != nil {
-		return err
+		return errors.Wrap(code.InvalidPluginFile, err.Error())
 	}
 
 	templateContent := &pluginTemplate{
@@ -187,26 +188,31 @@ func buildGo(path string, output string) error {
 		Version:       version.VERSION,
 		FunctionNames: functionNames,
 	}
-	return templateContent.generateGo(output)
+	err = templateContent.generateGo(output)
+	if err != nil {
+		return errors.Wrap(code.BuildGoPluginFailed, err.Error())
+	}
+	return nil
 }
 
 // buildPy completes funppy information in debugtalk.py
 func buildPy(path string, output string) error {
 	log.Info().Str("path", path).Str("output", output).Msg("start to prepare python plugin")
 	// check the syntax of debugtalk.py
-	err := builtin.ExecPython3Command("py_compile", path)
+	err := myexec.ExecPython3Command("py_compile", path)
 	if err != nil {
-		return errors.Wrap(err, "python plugin syntax invalid")
+		return errors.Wrap(code.InvalidPluginFile,
+			fmt.Sprintf("python plugin syntax invalid: %s", err.Error()))
 	}
 
 	content, err := os.ReadFile(path)
 	if err != nil {
 		log.Error().Err(err).Msg("failed to read file")
-		return errors.Wrap(err, "read file failed")
+		return errors.Wrap(code.LoadFileError, err.Error())
 	}
 	functionNames, err := regexPyFunctionName.findAllFunctionNames(string(content))
 	if err != nil {
-		return err
+		return errors.Wrap(code.InvalidPluginFile, err.Error())
 	}
 
 	templateContent := &pluginTemplate{
@@ -214,7 +220,11 @@ func buildPy(path string, output string) error {
 		Version:       version.VERSION,
 		FunctionNames: functionNames,
 	}
-	return templateContent.generatePy(output)
+	err = templateContent.generatePy(output)
+	if err != nil {
+		return errors.Wrap(code.BuildPyPluginFailed, err.Error())
+	}
+	return nil
 }
 
 func BuildPlugin(path string, output string) (err error) {
@@ -225,11 +235,12 @@ func BuildPlugin(path string, output string) (err error) {
 	case ".go":
 		err = buildGo(path, output)
 	default:
-		return errors.New("type error, expected .py or .go")
+		return errors.Wrap(code.UnsupportedFileExtension,
+			"type error, expected .py or .go")
 	}
 	if err != nil {
 		log.Error().Err(err).Str("path", path).Msg("build plugin failed")
-		os.Exit(1)
+		return err
 	}
 	return nil
 }
diff --git a/hrp/cmd/adb/devices.go b/hrp/cmd/adb/devices.go
new file mode 100644
index 000000000..7cd53b69f
--- /dev/null
+++ b/hrp/cmd/adb/devices.go
@@ -0,0 +1,62 @@
+package adb
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/electricbubble/gadb"
+	"github.com/pkg/errors"
+	"github.com/spf13/cobra"
+
+	"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
+)
+
+func format(data map[string]string) string {
+	result, _ := json.MarshalIndent(data, "", "\t")
+	return string(result)
+}
+
+var listAndroidDevicesCmd = &cobra.Command{
+	Use:   "devices",
+	Short: "List all Android devices",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		devices, err := uixt.DeviceList()
+		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)
+		}
+
+		for _, d := range deviceList {
+			if isDetail {
+				fmt.Println(format(d.DeviceInfo()))
+			} else {
+				fmt.Println(d.Serial(), d.Usb())
+			}
+		}
+		return nil
+	},
+}
+
+var (
+	serial   string
+	isDetail bool
+)
+
+func init() {
+	listAndroidDevicesCmd.Flags().StringVarP(&serial, "serial", "s", "", "filter by device's serial")
+	listAndroidDevicesCmd.Flags().BoolVarP(&isDetail, "detail", "d", false, "print device's detail")
+	androidRootCmd.AddCommand(listAndroidDevicesCmd)
+}
diff --git a/hrp/cmd/adb/init.go b/hrp/cmd/adb/init.go
new file mode 100644
index 000000000..9025ef70b
--- /dev/null
+++ b/hrp/cmd/adb/init.go
@@ -0,0 +1,13 @@
+package adb
+
+import "github.com/spf13/cobra"
+
+var androidRootCmd = &cobra.Command{
+	Use:              "adb",
+	Short:            "simple utils for android device management",
+	PersistentPreRun: func(cmd *cobra.Command, args []string) {},
+}
+
+func Init(rootCmd *cobra.Command) {
+	rootCmd.AddCommand(androidRootCmd)
+}
diff --git a/hrp/cmd/boom.go b/hrp/cmd/boom.go
index afe10253c..08cff6597 100644
--- a/hrp/cmd/boom.go
+++ b/hrp/cmd/boom.go
@@ -1,7 +1,6 @@
 package cmd
 
 import (
-	"os"
 	"strings"
 	"time"
 
@@ -10,8 +9,8 @@ import (
 	"golang.org/x/net/context"
 
 	"github.com/httprunner/httprunner/v4/hrp"
-	"github.com/httprunner/httprunner/v4/hrp/internal/boomer"
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/boomer"
 )
 
 // boomCmd represents the boom command
@@ -30,7 +29,7 @@ var boomCmd = &cobra.Command{
 		}
 		setLogLevel(logLevel)
 	},
-	Run: func(cmd *cobra.Command, args []string) {
+	RunE: func(cmd *cobra.Command, args []string) error {
 		var paths []hrp.ITestCase
 		for _, arg := range args {
 			path := hrp.TestCasePath(arg)
@@ -42,7 +41,7 @@ var boomCmd = &cobra.Command{
 			err := builtin.LoadFile(boomArgs.profile, &boomArgs.Profile)
 			if err != nil {
 				log.Error().Err(err).Msg("failed to load profile")
-				os.Exit(1)
+				return err
 			}
 		}
 
@@ -89,6 +88,7 @@ var boomCmd = &cobra.Command{
 			hrpBoomer.InitBoomer()
 			hrpBoomer.Run(paths...)
 		}
+		return nil
 	},
 }
 
@@ -141,13 +141,13 @@ func init() {
 	boomCmd.Flags().IntVar(&boomArgs.expectWorkersMaxWait, "expect-workers-max-wait", 120, "How many workers master should expect to connect before starting the test (only when --autostart is used")
 }
 
-func makeHRPBoomer() *hrp.HRPBoomer {
+func makeHRPBoomer() (*hrp.HRPBoomer, error) {
 	// if set profile, the priority is higher than the other commands
 	if boomArgs.profile != "" {
 		err := builtin.LoadFile(boomArgs.profile, &boomArgs)
 		if err != nil {
 			log.Error().Err(err).Msg("failed to load profile")
-			os.Exit(1)
+			return nil, err
 		}
 	}
 	hrpBoomer := hrp.NewStandaloneBoomer(boomArgs.SpawnCount, boomArgs.SpawnRate)
@@ -157,5 +157,5 @@ func makeHRPBoomer() *hrp.HRPBoomer {
 	hrpBoomer.SetProfile(&boomArgs.Profile)
 	hrpBoomer.EnableGracefulQuit(context.Background())
 	hrpBoomer.InitBoomer()
-	return hrpBoomer
+	return hrpBoomer, nil
 }
diff --git a/hrp/cmd/cli/main.go b/hrp/cmd/cli/main.go
index 63f5c9bdc..453891a1b 100644
--- a/hrp/cmd/cli/main.go
+++ b/hrp/cmd/cli/main.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"os"
 	"time"
 
 	"github.com/getsentry/sentry-go"
@@ -20,5 +21,5 @@ func main() {
 		}
 	}()
 
-	cmd.Execute()
+	os.Exit(cmd.Execute())
 }
diff --git a/hrp/cmd/convert.go b/hrp/cmd/convert.go
index 27163f607..5ec004806 100644
--- a/hrp/cmd/convert.go
+++ b/hrp/cmd/convert.go
@@ -7,9 +7,9 @@ import (
 	"github.com/rs/zerolog/log"
 	"github.com/spf13/cobra"
 
-	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
-	"github.com/httprunner/httprunner/v4/hrp/internal/convert"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
 	"github.com/httprunner/httprunner/v4/hrp/internal/version"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/convert"
 )
 
 var convertCmd = &cobra.Command{
@@ -61,9 +61,9 @@ func convertRun(cmd *cobra.Command, args []string) error {
 		outputType = convert.OutputTypePyTest
 
 		packages := []string{
-			fmt.Sprintf("httprunner==%s", version.VERSION),
+			fmt.Sprintf("httprunner==%s", version.HttpRunnerMinimumVersion),
 		}
-		_, err := builtin.EnsurePython3Venv(venv, packages...)
+		_, err := myexec.EnsurePython3Venv(venv, packages...)
 		if err != nil {
 			log.Error().Err(err).Msg("python3 venv is not ready")
 			return err
diff --git a/hrp/cmd/curl.go b/hrp/cmd/curl.go
index 214c880d8..2fdda0cc2 100644
--- a/hrp/cmd/curl.go
+++ b/hrp/cmd/curl.go
@@ -9,8 +9,8 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/httprunner/httprunner/v4/hrp"
-	"github.com/httprunner/httprunner/v4/hrp/internal/boomer"
-	"github.com/httprunner/httprunner/v4/hrp/internal/convert"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/boomer"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/convert"
 )
 
 var runCurlCmd = &cobra.Command{
@@ -21,11 +21,9 @@ var runCurlCmd = &cobra.Command{
 	PreRun: func(cmd *cobra.Command, args []string) {
 		setLogLevel(logLevel)
 	},
-	Run: func(cmd *cobra.Command, args []string) {
+	RunE: func(cmd *cobra.Command, args []string) error {
 		runner := makeHRPRunner()
-		if runner.Run(makeCurlTestCase(args)) != nil {
-			os.Exit(1)
-		}
+		return runner.Run(makeCurlTestCase(args))
 	},
 }
 
@@ -41,9 +39,13 @@ var boomCurlCmd = &cobra.Command{
 		}
 		setLogLevel(logLevel)
 	},
-	Run: func(cmd *cobra.Command, args []string) {
-		boomer := makeHRPBoomer()
+	RunE: func(cmd *cobra.Command, args []string) error {
+		boomer, err := makeHRPBoomer()
+		if err != nil {
+			return err
+		}
 		boomer.Run(makeCurlTestCase(args))
+		return nil
 	},
 }
 
diff --git a/hrp/cmd/ios/apps.go b/hrp/cmd/ios/apps.go
new file mode 100644
index 000000000..d8a62f415
--- /dev/null
+++ b/hrp/cmd/ios/apps.go
@@ -0,0 +1,63 @@
+package ios
+
+import (
+	"fmt"
+
+	giDevice "github.com/electricbubble/gidevice"
+	"github.com/mitchellh/mapstructure"
+	"github.com/spf13/cobra"
+)
+
+type Application struct {
+	CFBundleVersion     string `json:"version"`
+	CFBundleDisplayName string `json:"name"`
+	CFBundleIdentifier  string `json:"bundleId"`
+}
+
+var listAppsCmd = &cobra.Command{
+	Use:              "apps",
+	Short:            "List all iOS installed apps",
+	PersistentPreRun: func(cmd *cobra.Command, args []string) {},
+	RunE: func(cmd *cobra.Command, args []string) error {
+		device, err := getDevice(udid)
+		if err != nil {
+			return err
+		}
+
+		var applicationType giDevice.ApplicationType
+		switch appType {
+		case "user":
+			applicationType = giDevice.ApplicationTypeUser
+		case "system":
+			applicationType = giDevice.ApplicationTypeSystem
+		case "internal":
+			applicationType = giDevice.ApplicationTypeInternal
+		case "all":
+			applicationType = giDevice.ApplicationTypeAny
+		}
+
+		result, errList := device.InstallationProxyBrowse(
+			giDevice.WithApplicationType(applicationType),
+			giDevice.WithReturnAttributes("CFBundleVersion", "CFBundleDisplayName", "CFBundleIdentifier"))
+		if errList != nil {
+			return fmt.Errorf("get app list failed")
+		}
+
+		for _, app := range result {
+			a := Application{}
+			mapstructure.Decode(app, &a)
+
+			fmt.Printf("%-30.30s %-50.50s %-s\n",
+				a.CFBundleDisplayName, a.CFBundleIdentifier, a.CFBundleVersion)
+		}
+		return nil
+	},
+}
+
+var appType string
+
+func init() {
+	listAppsCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
+	listAppsCmd.Flags().StringVarP(&appType, "type", "t", "user", "filter application type [user|system|internal|all]")
+	iosRootCmd.AddCommand(listAppsCmd)
+}
diff --git a/hrp/cmd/ios/devices.go b/hrp/cmd/ios/devices.go
new file mode 100644
index 000000000..0553d82a3
--- /dev/null
+++ b/hrp/cmd/ios/devices.go
@@ -0,0 +1,118 @@
+package ios
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	giDevice "github.com/electricbubble/gidevice"
+	"github.com/pkg/errors"
+	"github.com/spf13/cobra"
+
+	"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
+)
+
+type Device struct {
+	d               giDevice.Device
+	UDID            string        `json:"UDID"`
+	Status          string        `json:"status"`
+	ConnectionType  string        `json:"connectionType"`
+	ConnectionSpeed int           `json:"connectionSpeed"`
+	DeviceDetail    *DeviceDetail `json:"deviceDetail,omitempty"`
+}
+
+type DeviceDetail struct {
+	DeviceName        string `json:"deviceName,omitempty"`
+	DeviceClass       string `json:"deviceClass,omitempty"`
+	ProductVersion    string `json:"productVersion,omitempty"`
+	ProductType       string `json:"productType,omitempty"`
+	ProductName       string `json:"productName,omitempty"`
+	PasswordProtected bool   `json:"passwordProtected,omitempty"`
+	ModelNumber       string `json:"modelNumber,omitempty"`
+	SerialNumber      string `json:"serialNumber,omitempty"`
+	SIMStatus         string `json:"simStatus,omitempty"`
+	PhoneNumber       string `json:"phoneNumber,omitempty"`
+	CPUArchitecture   string `json:"cpuArchitecture,omitempty"`
+	ProtocolVersion   string `json:"protocolVersion,omitempty"`
+	RegionInfo        string `json:"regionInfo,omitempty"`
+	TimeZone          string `json:"timeZone,omitempty"`
+	UniqueDeviceID    string `json:"uniqueDeviceID,omitempty"`
+	WiFiAddress       string `json:"wifiAddress,omitempty"`
+	BuildVersion      string `json:"buildVersion,omitempty"`
+}
+
+func (device *Device) GetDetail() (*DeviceDetail, error) {
+	value, err := device.d.GetValue("", "")
+	if err != nil {
+		return nil, errors.Wrap(err, "get device detail failed")
+	}
+	detailByte, _ := json.Marshal(value)
+	detail := &DeviceDetail{}
+	json.Unmarshal(detailByte, detail)
+	return detail, nil
+}
+
+func (device *Device) GetStatus() string {
+	if device.ConnectionType != "" {
+		return "online"
+	} else {
+		return "offline"
+	}
+}
+
+func (device *Device) ToFormat() string {
+	result, _ := json.MarshalIndent(device, "", "\t")
+	return string(result)
+}
+
+var listDevicesCmd = &cobra.Command{
+	Use:              "devices",
+	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)
+		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)
+			}
+		}
+
+		for _, d := range devices {
+			deviceByte, _ := json.Marshal(d.Properties())
+			device := &Device{
+				d: d,
+			}
+			json.Unmarshal(deviceByte, device)
+			device.Status = device.GetStatus()
+
+			if isDetail {
+				device.DeviceDetail, err = device.GetDetail()
+				if err != nil {
+					return err
+				}
+				fmt.Println(device.ToFormat())
+			} else {
+				fmt.Println(device.UDID, device.ConnectionType, device.Status)
+			}
+		}
+		return nil
+	},
+}
+
+var (
+	udid     string
+	isDetail bool
+)
+
+func init() {
+	listDevicesCmd.Flags().StringVarP(&udid, "udid", "u", "", "filter by device's udid")
+	listDevicesCmd.Flags().BoolVarP(&isDetail, "detail", "d", false, "print device's detail")
+	iosRootCmd.AddCommand(listDevicesCmd)
+}
diff --git a/hrp/cmd/ios/init.go b/hrp/cmd/ios/init.go
new file mode 100644
index 000000000..db1f63a9f
--- /dev/null
+++ b/hrp/cmd/ios/init.go
@@ -0,0 +1,35 @@
+package ios
+
+import (
+	"fmt"
+	"os"
+
+	giDevice "github.com/electricbubble/gidevice"
+	"github.com/spf13/cobra"
+
+	"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
+)
+
+var iosRootCmd = &cobra.Command{
+	Use:   "ios",
+	Short: "simple utils for ios device management",
+}
+
+func getDevice(udid string) (giDevice.Device, error) {
+	devices, err := uixt.IOSDevices(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 devices[0], nil
+}
+
+func Init(rootCmd *cobra.Command) {
+	rootCmd.AddCommand(iosRootCmd)
+}
diff --git a/hrp/cmd/ios/mount.go b/hrp/cmd/ios/mount.go
new file mode 100644
index 000000000..539722206
--- /dev/null
+++ b/hrp/cmd/ios/mount.go
@@ -0,0 +1,90 @@
+package ios
+
+import (
+	"encoding/base64"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/rs/zerolog/log"
+	"github.com/spf13/cobra"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+)
+
+// mountCmd represents the mount command
+var mountCmd = &cobra.Command{
+	Use:   "mount",
+	Short: "A brief description of your command",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		device, err := getDevice(udid)
+		if err != nil {
+			return err
+		}
+
+		value, err := device.GetValue("", "ProductVersion")
+		if err != nil {
+			return fmt.Errorf("get device ProductVersion failed: %v", err)
+		}
+		log.Info().Str("version", value.(string)).Msg("get device version")
+
+		imageSignatures, errImage := device.Images()
+
+		if listDeveloperDiskImage {
+			for i, imgSign := range imageSignatures {
+				fmt.Printf("[%d] %s\n", i+1, base64.StdEncoding.EncodeToString(imgSign))
+			}
+			return nil
+		}
+
+		if errImage == nil && len(imageSignatures) > 0 {
+			log.Info().Msg("ios developer image is already mounted")
+			return nil
+		}
+
+		log.Info().Str("dir", developerDiskImageDir).Msg("start to mount ios developer image")
+
+		if !builtin.IsFolderPathExists(developerDiskImageDir) {
+			return fmt.Errorf("developer disk image directory not exist: %s", developerDiskImageDir)
+		}
+
+		ver := strings.Split(value.(string), ".")
+		if len(ver) < 2 {
+			return fmt.Errorf("got invalid device ProductVersion: %v", value)
+		}
+		version := ver[0] + "." + ver[1]
+
+		var dmgPath, signaturePath string
+		if builtin.IsFilePathExists(filepath.Join(developerDiskImageDir, "DeveloperDiskImage.dmg")) {
+			dmgPath = filepath.Join(developerDiskImageDir, "DeveloperDiskImage.dmg")
+			signaturePath = filepath.Join(developerDiskImageDir, "DeveloperDiskImage.dmg.signature")
+		} else if builtin.IsFilePathExists(filepath.Join(developerDiskImageDir, version, "DeveloperDiskImage.dmg.")) {
+			dmgPath = filepath.Join(developerDiskImageDir, version, "DeveloperDiskImage.dmg")
+			signaturePath = filepath.Join(developerDiskImageDir, version, "DeveloperDiskImage.dmg.signature")
+		} else {
+			log.Error().Str("dir", developerDiskImageDir).Msg("developer disk image not found in directory")
+			return fmt.Errorf("developer disk image not found")
+		}
+
+		if err = device.MountDeveloperDiskImage(dmgPath, signaturePath); err != nil {
+			return fmt.Errorf("mount developer disk image failed: %s", err)
+		}
+
+		log.Info().Msg("mount developer disk image successfully")
+		return nil
+	},
+}
+
+const defaultDeveloperDiskImageDir = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport/"
+
+var (
+	developerDiskImageDir  string
+	listDeveloperDiskImage bool
+)
+
+func init() {
+	mountCmd.Flags().BoolVar(&listDeveloperDiskImage, "list", false, "list developer disk images")
+	mountCmd.Flags().StringVarP(&developerDiskImageDir, "dir", "d", defaultDeveloperDiskImageDir, "specify DeveloperDiskImage directory")
+	mountCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
+	iosRootCmd.AddCommand(mountCmd)
+}
diff --git a/hrp/cmd/ios/ps.go b/hrp/cmd/ios/ps.go
new file mode 100644
index 000000000..d40dcc4d7
--- /dev/null
+++ b/hrp/cmd/ios/ps.go
@@ -0,0 +1,61 @@
+package ios
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/pkg/errors"
+	"github.com/spf13/cobra"
+)
+
+var psCmd = &cobra.Command{
+	Use:              "ps",
+	Short:            "show running processes",
+	PersistentPreRun: func(cmd *cobra.Command, args []string) {},
+	RunE: func(cmd *cobra.Command, args []string) error {
+		device, err := getDevice(udid)
+		if err != nil {
+			return err
+		}
+
+		apps, err := device.AppList()
+		if err != nil {
+			return errors.Wrap(err, "get ios apps failed")
+		}
+
+		maxNameLen := 0
+		mapper := make(map[string]interface{})
+		for _, app := range apps {
+			mapper[app.ExecutableName] = app.CFBundleIdentifier
+			if len(app.ExecutableName) > maxNameLen {
+				maxNameLen = len(app.ExecutableName)
+			}
+		}
+
+		runningProcesses, err := device.AppRunningProcesses()
+		if err != nil {
+			return errors.Wrap(err, "get running processes failed")
+		}
+		for _, p := range runningProcesses {
+			if !isAll && !p.IsApplication {
+				continue
+			}
+			bundleID, ok := mapper[p.Name]
+			if !ok {
+				bundleID = ""
+			}
+
+			fmt.Printf("%4d %-"+fmt.Sprintf("%d", maxNameLen)+"s %20s %s\n",
+				p.Pid, p.Name, time.Since(p.StartDate).String(), bundleID)
+		}
+		return nil
+	},
+}
+
+var isAll bool
+
+func init() {
+	psCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
+	psCmd.Flags().BoolVarP(&isAll, "all", "a", false, "print all processes including system processes")
+	iosRootCmd.AddCommand(psCmd)
+}
diff --git a/hrp/cmd/ios/reboot.go b/hrp/cmd/ios/reboot.go
new file mode 100644
index 000000000..d38d9db86
--- /dev/null
+++ b/hrp/cmd/ios/reboot.go
@@ -0,0 +1,38 @@
+package ios
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+var rebootCmd = &cobra.Command{
+	Use:              "reboot",
+	Short:            "reboot or shutdown ios device",
+	PersistentPreRun: func(cmd *cobra.Command, args []string) {},
+	RunE: func(cmd *cobra.Command, args []string) error {
+		device, err := getDevice(udid)
+		if err != nil {
+			return err
+		}
+
+		if isShutdown {
+			err = device.Shutdown()
+		} else {
+			err = device.Reboot()
+		}
+		if err != nil {
+			return err
+		}
+		fmt.Printf("reboot %s success\n", device.Properties().UDID)
+		return nil
+	},
+}
+
+var isShutdown bool
+
+func init() {
+	rebootCmd.Flags().StringVarP(&udid, "udid", "u", "", "specify device by udid")
+	rebootCmd.Flags().BoolVarP(&isShutdown, "shutdown", "s", false, "shutdown ios device")
+	iosRootCmd.AddCommand(rebootCmd)
+}
diff --git a/hrp/cmd/root.go b/hrp/cmd/root.go
index 4ecb63f8d..2cc888e83 100644
--- a/hrp/cmd/root.go
+++ b/hrp/cmd/root.go
@@ -9,6 +9,9 @@ import (
 	"github.com/rs/zerolog/log"
 	"github.com/spf13/cobra"
 
+	"github.com/httprunner/httprunner/v4/hrp/cmd/adb"
+	"github.com/httprunner/httprunner/v4/hrp/cmd/ios"
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
 	"github.com/httprunner/httprunner/v4/hrp/internal/version"
 )
 
@@ -39,11 +42,12 @@ Copyright 2017 debugtalk`,
 		}
 		if !logJSON {
 			log.Logger = zerolog.New(zerolog.ConsoleWriter{NoColor: noColor, Out: os.Stderr}).With().Timestamp().Logger()
-			log.Info().Msg("Set log to color console other than JSON format.")
+			log.Info().Msg("Set log to color console")
 		}
 	},
 	Version:          version.VERSION,
-	TraverseChildren: true,
+	TraverseChildren: true, // parses flags on all parents before executing child command
+	SilenceUsage:     true, // silence usage when an error occurs
 }
 
 var (
@@ -54,14 +58,16 @@ var (
 
 // Execute adds all child commands to the root command and sets flags appropriately.
 // This is called by main.main(). It only needs to happen once to the rootCmd.
-func Execute() {
+func Execute() int {
 	rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "INFO", "set log level")
 	rootCmd.PersistentFlags().BoolVar(&logJSON, "log-json", false, "set log to json format")
 	rootCmd.PersistentFlags().StringVar(&venv, "venv", "", "specify python3 venv path")
 
-	if err := rootCmd.Execute(); err != nil {
-		os.Exit(1)
-	}
+	ios.Init(rootCmd)
+	adb.Init(rootCmd)
+
+	err := rootCmd.Execute()
+	return code.GetErrorCode(err)
 }
 
 func setLogLevel(level string) {
diff --git a/hrp/cmd/run.go b/hrp/cmd/run.go
index c628f93af..5a7b4f8e1 100644
--- a/hrp/cmd/run.go
+++ b/hrp/cmd/run.go
@@ -1,8 +1,6 @@
 package cmd
 
 import (
-	"os"
-
 	"github.com/spf13/cobra"
 
 	"github.com/httprunner/httprunner/v4/hrp"
@@ -20,17 +18,14 @@ var runCmd = &cobra.Command{
 	PreRun: func(cmd *cobra.Command, args []string) {
 		setLogLevel(logLevel)
 	},
-	Run: func(cmd *cobra.Command, args []string) {
+	RunE: func(cmd *cobra.Command, args []string) error {
 		var paths []hrp.ITestCase
 		for _, arg := range args {
 			path := hrp.TestCasePath(arg)
 			paths = append(paths, &path)
 		}
 		runner := makeHRPRunner()
-		err := runner.Run(paths...)
-		if err != nil {
-			os.Exit(1)
-		}
+		return runner.Run(paths...)
 	},
 }
 
diff --git a/hrp/cmd/scaffold.go b/hrp/cmd/scaffold.go
index 868307254..b127b0abb 100644
--- a/hrp/cmd/scaffold.go
+++ b/hrp/cmd/scaffold.go
@@ -2,7 +2,6 @@ package cmd
 
 import (
 	"errors"
-	"os"
 
 	"github.com/rs/zerolog/log"
 	"github.com/spf13/cobra"
@@ -37,7 +36,7 @@ var scaffoldCmd = &cobra.Command{
 		err := scaffold.CreateScaffold(args[0], pluginType, venv, force)
 		if err != nil {
 			log.Error().Err(err).Msg("create scaffold project failed")
-			os.Exit(1)
+			return err
 		}
 		log.Info().Str("projectName", args[0]).Msg("create scaffold success")
 		return nil
diff --git a/hrp/config.go b/hrp/config.go
index 3ee1264aa..9c03278ff 100644
--- a/hrp/config.go
+++ b/hrp/config.go
@@ -5,6 +5,7 @@ import (
 	"time"
 
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
 )
 
 // NewConfig returns a new constructed testcase config with specified testcase name.
@@ -29,6 +30,8 @@ type TConfig struct {
 	ParametersSetting *TParamsConfig         `json:"parameters_setting,omitempty" yaml:"parameters_setting,omitempty"`
 	ThinkTimeSetting  *ThinkTimeConfig       `json:"think_time,omitempty" yaml:"think_time,omitempty"`
 	WebSocketSetting  *WebSocketConfig       `json:"websocket,omitempty" yaml:"websocket,omitempty"`
+	IOS               []*uixt.IOSDevice      `json:"ios,omitempty" yaml:"ios,omitempty"`
+	Android           []*uixt.AndroidDevice  `json:"android,omitempty" yaml:"android,omitempty"`
 	Timeout           float64                `json:"timeout,omitempty" yaml:"timeout,omitempty"` // global timeout in seconds
 	Export            []string               `json:"export,omitempty" yaml:"export,omitempty"`
 	Weight            int                    `json:"weight,omitempty" yaml:"weight,omitempty"`
@@ -90,12 +93,55 @@ func (c *TConfig) SetWeight(weight int) *TConfig {
 	return c
 }
 
-func (c *TConfig) SetWebSocket(times, interval, timeout, size int64) {
+func (c *TConfig) SetWebSocket(times, interval, timeout, size int64) *TConfig {
 	c.WebSocketSetting = &WebSocketConfig{
 		ReconnectionTimes:    times,
 		ReconnectionInterval: interval,
 		MaxMessageSize:       size,
 	}
+	return c
+}
+
+func (c *TConfig) SetIOS(options ...uixt.IOSDeviceOption) *TConfig {
+	wdaOptions := &uixt.IOSDevice{}
+	for _, option := range options {
+		option(wdaOptions)
+	}
+
+	// each device can have its own settings
+	if wdaOptions.UDID != "" {
+		c.IOS = append(c.IOS, wdaOptions)
+		return c
+	}
+
+	// device UDID is not specified, settings will be shared
+	if len(c.IOS) == 0 {
+		c.IOS = append(c.IOS, wdaOptions)
+	} else {
+		c.IOS[0] = wdaOptions
+	}
+	return c
+}
+
+func (c *TConfig) SetAndroid(options ...uixt.AndroidDeviceOption) *TConfig {
+	uiaOptions := &uixt.AndroidDevice{}
+	for _, option := range options {
+		option(uiaOptions)
+	}
+
+	// each device can have its own settings
+	if uiaOptions.SerialNumber != "" {
+		c.Android = append(c.Android, uiaOptions)
+		return c
+	}
+
+	// device UDID is not specified, settings will be shared
+	if len(c.Android) == 0 {
+		c.Android = append(c.Android, uiaOptions)
+	} else {
+		c.Android[0] = uiaOptions
+	}
+	return c
 }
 
 type ThinkTimeConfig struct {
diff --git a/hrp/internal/builtin/function.go b/hrp/internal/builtin/function.go
index a5b3c36f0..00d01d973 100644
--- a/hrp/internal/builtin/function.go
+++ b/hrp/internal/builtin/function.go
@@ -28,6 +28,7 @@ var Functions = map[string]interface{}{
 	"md5":                    MD5,             // call with one argument
 	"parameterize":           loadFromCSV,
 	"P":                      loadFromCSV,
+	"split_by_comma":         splitByComma, // call with one argument
 	"environ":                os.Getenv,
 	"ENV":                    os.Getenv,
 	"load_ws_message":        loadMessage,
@@ -225,3 +226,7 @@ func multipartContentType(w *TFormDataWriter) string {
 	}
 	return w.Writer.FormDataContentType()
 }
+
+func splitByComma(s string) []string {
+	return strings.Split(s, ",")
+}
diff --git a/hrp/internal/builtin/utils.go b/hrp/internal/builtin/utils.go
index 07157cdbf..2f109a5bb 100644
--- a/hrp/internal/builtin/utils.go
+++ b/hrp/internal/builtin/utils.go
@@ -3,6 +3,8 @@ package builtin
 import (
 	"bufio"
 	"bytes"
+	"crypto/hmac"
+	"crypto/sha256"
 	"encoding/binary"
 	"encoding/csv"
 	builtinJSON "encoding/json"
@@ -10,16 +12,17 @@ import (
 	"math"
 	"math/rand"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"reflect"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
 	"gopkg.in/yaml.v3"
 
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
 	"github.com/httprunner/httprunner/v4/hrp/internal/json"
 )
 
@@ -90,117 +93,6 @@ func FormatResponse(raw interface{}) interface{} {
 	return formattedResponse
 }
 
-var python3Executable string = "python3" // system default python3
-
-// EnsurePython3Venv ensures python3 venv with specified packages
-// venv should be directory path of target venv
-func EnsurePython3Venv(venv string, packages ...string) (python3 string, err error) {
-	// priority: specified > $HOME/.hrp/venv
-	if venv == "" {
-		home, err := os.UserHomeDir()
-		if err != nil {
-			return "", errors.Wrap(err, "get user home dir failed")
-		}
-		venv = filepath.Join(home, ".hrp", "venv")
-	}
-	python3, err = ensurePython3Venv(venv, packages...)
-	if err != nil {
-		return "", errors.Wrap(err, "prepare python3 venv failed")
-	}
-	python3Executable = python3
-	log.Info().Str("Python3Executable", python3Executable).Msg("set python3 executable path")
-	return python3, nil
-}
-
-func ExecPython3Command(cmdName string, args ...string) error {
-	args = append([]string{"-m", cmdName}, args...)
-	return ExecCommand(python3Executable, args...)
-}
-
-func AssertPythonPackage(python3 string, pkgName, pkgVersion string) error {
-	out, err := exec.Command(
-		python3, "-c", fmt.Sprintf("import %s; print(%s.__version__)", pkgName, pkgName),
-	).Output()
-	if err != nil {
-		return fmt.Errorf("python package %s not found", pkgName)
-	}
-
-	// do not check version if pkgVersion is empty
-	if pkgVersion == "" {
-		log.Info().Str("name", pkgName).Msg("python package is ready")
-		return nil
-	}
-
-	// check package version equality
-	version := strings.TrimSpace(string(out))
-	if strings.TrimLeft(version, "v") != strings.TrimLeft(pkgVersion, "v") {
-		return fmt.Errorf("python package %s version %s not matched, please upgrade to %s",
-			pkgName, version, pkgVersion)
-	}
-
-	log.Info().Str("name", pkgName).Str("version", pkgVersion).Msg("python package is ready")
-	return nil
-}
-
-func InstallPythonPackage(python3 string, pkg string) (err error) {
-	var pkgName, pkgVersion string
-	if strings.Contains(pkg, "==") {
-		// funppy==0.5.0
-		pkgInfo := strings.Split(pkg, "==")
-		pkgName = pkgInfo[0]
-		pkgVersion = pkgInfo[1]
-	} else {
-		// funppy
-		pkgName = pkg
-	}
-
-	// check if package installed and version matched
-	err = AssertPythonPackage(python3, pkgName, pkgVersion)
-	if err == nil {
-		return nil
-	}
-
-	// check if pip available
-	err = ExecCommand(python3, "-m", "pip", "--version")
-	if err != nil {
-		log.Warn().Msg("pip is not available")
-		return errors.Wrap(err, "pip is not available")
-	}
-
-	log.Info().Str("pkgName", pkgName).Str("pkgVersion", pkgVersion).Msg("installing python package")
-
-	// install package
-	pypiIndexURL := os.Getenv("PYPI_INDEX_URL")
-	if pypiIndexURL == "" {
-		pypiIndexURL = "https://pypi.org/simple" // default
-	}
-	err = ExecCommand(python3, "-m", "pip", "install", "--upgrade", pkg,
-		"--index-url", pypiIndexURL,
-		"--quiet", "--disable-pip-version-check")
-	if err != nil {
-		return errors.Wrap(err, "pip install package failed")
-	}
-
-	return AssertPythonPackage(python3, pkgName, pkgVersion)
-}
-
-func ExecCommandInDir(cmd *exec.Cmd, dir string) error {
-	log.Info().Str("cmd", cmd.String()).Str("dir", dir).Msg("exec command")
-	cmd.Dir = dir
-
-	// print output with colors
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-
-	err := cmd.Run()
-	if err != nil {
-		log.Error().Err(err).Msg("exec command failed")
-		return err
-	}
-
-	return nil
-}
-
 func CreateFolder(folderPath string) error {
 	log.Info().Str("path", folderPath).Msg("create folder")
 	err := os.MkdirAll(folderPath, os.ModePerm)
@@ -285,19 +177,19 @@ func GetRandomNumber(min, max int) int {
 }
 
 func Interface2Float64(i interface{}) (float64, error) {
-	switch i.(type) {
+	switch v := i.(type) {
 	case int:
-		return float64(i.(int)), nil
+		return float64(v), nil
 	case int32:
-		return float64(i.(int32)), nil
+		return float64(v), nil
 	case int64:
-		return float64(i.(int64)), nil
+		return float64(v), nil
 	case float32:
-		return float64(i.(float32)), nil
+		return float64(v), nil
 	case float64:
-		return i.(float64), nil
+		return v, nil
 	case string:
-		intVar, err := strconv.Atoi(i.(string))
+		intVar, err := strconv.Atoi(v)
 		if err != nil {
 			return 0, err
 		}
@@ -344,8 +236,6 @@ func InterfaceType(raw interface{}) string {
 	return reflect.TypeOf(raw).String()
 }
 
-var ErrUnsupportedFileExt = fmt.Errorf("unsupported file extension")
-
 // LoadFile loads file content with file extension and assigns to structObj
 func LoadFile(path string, structObj interface{}) (err error) {
 	log.Info().Str("path", path).Msg("load file")
@@ -361,12 +251,21 @@ func LoadFile(path string, structObj interface{}) (err error) {
 		decoder := json.NewDecoder(bytes.NewReader(file))
 		decoder.UseNumber()
 		err = decoder.Decode(structObj)
+		if err != nil {
+			err = errors.Wrap(code.LoadJSONError, err.Error())
+		}
 	case ".yaml", ".yml":
 		err = yaml.Unmarshal(file, structObj)
+		if err != nil {
+			err = errors.Wrap(code.LoadYAMLError, err.Error())
+		}
 	case ".env":
 		err = parseEnvContent(file, structObj)
+		if err != nil {
+			err = errors.Wrap(code.LoadEnvError, err.Error())
+		}
 	default:
-		err = ErrUnsupportedFileExt
+		err = code.UnsupportedFileExtension
 	}
 	return err
 }
@@ -406,14 +305,14 @@ func loadFromCSV(path string) []map[string]interface{} {
 	file, err := ReadFile(path)
 	if err != nil {
 		log.Error().Err(err).Msg("read csv file failed")
-		os.Exit(1)
+		os.Exit(code.GetErrorCode(err))
 	}
 
 	r := csv.NewReader(strings.NewReader(string(file)))
 	content, err := r.ReadAll()
 	if err != nil {
 		log.Error().Err(err).Msg("parse csv file failed")
-		os.Exit(1)
+		os.Exit(code.GetErrorCode(err))
 	}
 	firstLine := content[0] // parameter names
 	var result []map[string]interface{}
@@ -432,7 +331,7 @@ func loadMessage(path string) []byte {
 	file, err := ReadFile(path)
 	if err != nil {
 		log.Error().Err(err).Msg("read message file failed")
-		os.Exit(1)
+		os.Exit(code.GetErrorCode(err))
 	}
 	return file
 }
@@ -442,13 +341,13 @@ func ReadFile(path string) ([]byte, error) {
 	path, err = filepath.Abs(path)
 	if err != nil {
 		log.Error().Err(err).Str("path", path).Msg("convert absolute path failed")
-		return nil, err
+		return nil, errors.Wrap(code.LoadFileError, err.Error())
 	}
 
 	file, err := os.ReadFile(path)
 	if err != nil {
 		log.Error().Err(err).Msg("read file failed")
-		return nil, err
+		return nil, errors.Wrap(code.LoadFileError, err.Error())
 	}
 	return file, nil
 }
@@ -494,7 +393,7 @@ func GetFileNameWithoutExtension(path string) string {
 }
 
 func Bytes2File(data []byte, filename string) error {
-	file, err := os.OpenFile(filename, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0755)
+	file, err := os.OpenFile(filename, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0o755)
 	defer file.Close()
 	if err != nil {
 		log.Error().Err(err).Msg("failed to generate file")
@@ -564,3 +463,18 @@ func SplitInteger(m, n int) (ints []int) {
 	}
 	return
 }
+
+func sha256HMAC(key []byte, data []byte) []byte {
+	mac := hmac.New(sha256.New, key)
+	mac.Write(data)
+	return []byte(fmt.Sprintf("%x", mac.Sum(nil)))
+}
+
+// ver: auth-v1or auth-v2
+func Sign(ver string, ak string, sk string, body []byte) string {
+	expiration := 1800
+	signKeyInfo := fmt.Sprintf("%s/%s/%d/%d", ver, ak, time.Now().Unix(), expiration)
+	signKey := sha256HMAC([]byte(sk), []byte(signKeyInfo))
+	signResult := sha256HMAC(signKey, body)
+	return fmt.Sprintf("%v/%v", signKeyInfo, string(signResult))
+}
diff --git a/hrp/internal/code/code.go b/hrp/internal/code/code.go
new file mode 100644
index 000000000..48557401f
--- /dev/null
+++ b/hrp/internal/code/code.go
@@ -0,0 +1,156 @@
+package code
+
+import (
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+)
+
+// general: [0, 2)
+const (
+	Success     = 0
+	GeneralFail = 1
+)
+
+// environment: [2, 10)
+var (
+	InvalidPython3Venv = errors.New("prepare python3 venv failed") // 9
+)
+
+// loader: [10, 20)
+var (
+	LoadFileError            = errors.New("load file error")            // 10
+	LoadJSONError            = errors.New("load json error")            // 11
+	LoadYAMLError            = errors.New("load yaml error")            // 12
+	LoadEnvError             = errors.New("load .env error")            // 13
+	LoadCSVError             = errors.New("load csv error")             // 14
+	InvalidCaseFormat        = errors.New("invalid case format")        // 15
+	UnsupportedFileExtension = errors.New("unsupported file extension") // 16
+	ReferencedFileNotFound   = errors.New("referenced file not found")  // 17
+	InvalidPluginFile        = errors.New("invalid plugin file")        // 18
+)
+
+// parser: [20, 30)
+var (
+	ParseError          = errors.New("parse error")            // 20
+	VariableNotFound    = errors.New("variable not found")     // 21
+	ParseFunctionError  = errors.New("parse function failed")  // 22
+	CallFunctionError   = errors.New("call function failed")   // 23
+	ParseVariablesError = errors.New("parse variables failed") // 24
+)
+
+// runner: [30, 40)
+
+var (
+	InitPluginFailed    = errors.New("init plugin failed")     // 31
+	BuildGoPluginFailed = errors.New("build go plugin failed") // 32
+	BuildPyPluginFailed = errors.New("build py plugin failed") // 33
+)
+
+// summary: [40, 50)
+
+// ios device related: [50, 60)
+var (
+	IOSDeviceConnectionError = errors.New("ios device connection error")  // 50
+	IOSDeviceHTTPDriverError = errors.New("ios device HTTP driver error") // 51
+	IOSDeviceUSBDriverError  = errors.New("ios device USB driver error")  // 52
+	IOSScreenShotError       = errors.New("ios screenshot error")         // 55
+	IOSCaptureLogError       = errors.New("ios capture log error")        // 56
+)
+
+// android device related: [60, 70)
+var (
+	AndroidDeviceConnectionError = errors.New("android device connection error") // 60
+	AndroidDeviceDriverError     = errors.New("android device driver error")     // 61
+	AndroidScreenShotError       = errors.New("android screenshot error")        // 65
+	AndroidCaptureLogError       = errors.New("android capture log error")       // 66
+)
+
+// UI automation related: [70, 80)
+var (
+	MobileUIDriverError     = errors.New("mobile UI driver error")     // 70
+	MobileUIValidationError = errors.New("mobile UI validation error") // 75
+)
+
+// OCR related: [80, 90)
+var (
+	OCREnvMissedError         = errors.New("OCR env missed error")      // 80
+	OCRRequestError           = errors.New("OCR prepare request error") // 81
+	OCRServiceConnectionError = errors.New("OCR service connect error") // 82
+	OCRResponseError          = errors.New("OCR parse response error")  // 83
+	OCRTextNotFoundError      = errors.New("OCR text not found")        // 84
+)
+
+// CV related: [90, 100)
+
+var errorsMap = map[error]int{
+	// environment
+	InvalidPython3Venv: 9,
+
+	// loader
+	LoadFileError:            10,
+	LoadJSONError:            11,
+	LoadYAMLError:            12,
+	LoadEnvError:             13,
+	LoadCSVError:             14,
+	InvalidCaseFormat:        15,
+	UnsupportedFileExtension: 16,
+	ReferencedFileNotFound:   17,
+	InvalidPluginFile:        18,
+
+	// parser
+	ParseError:          20,
+	VariableNotFound:    21,
+	ParseFunctionError:  22,
+	CallFunctionError:   23,
+	ParseVariablesError: 24,
+
+	// runner
+	InitPluginFailed:    31,
+	BuildGoPluginFailed: 32,
+	BuildPyPluginFailed: 33,
+
+	// ios related
+	IOSDeviceConnectionError: 50,
+	IOSDeviceHTTPDriverError: 51,
+	IOSDeviceUSBDriverError:  52,
+	IOSScreenShotError:       55,
+	IOSCaptureLogError:       56,
+
+	// android related
+	AndroidDeviceConnectionError: 60,
+	AndroidDeviceDriverError:     61,
+	AndroidScreenShotError:       65,
+	AndroidCaptureLogError:       66,
+
+	// UI automation related
+	MobileUIDriverError:     70,
+	MobileUIValidationError: 75,
+
+	// OCR related
+	OCREnvMissedError:         80,
+	OCRRequestError:           81,
+	OCRServiceConnectionError: 82,
+	OCRResponseError:          83,
+	OCRTextNotFoundError:      84,
+}
+
+func IsErrorPredefined(err error) bool {
+	_, ok := errorsMap[err]
+	return ok
+}
+
+func GetErrorCode(err error) (exitCode int) {
+	if err == nil {
+		return Success
+	}
+
+	e := errors.Cause(err)
+	if code, ok := errorsMap[e]; ok {
+		exitCode = code
+	} else {
+		exitCode = GeneralFail
+	}
+
+	log.Warn().Int("code", exitCode).Msg("hrp exit")
+	return
+}
diff --git a/hrp/internal/dial/curl.go b/hrp/internal/dial/curl.go
index 8cd3436a7..f3940799e 100644
--- a/hrp/internal/dial/curl.go
+++ b/hrp/internal/dial/curl.go
@@ -4,13 +4,13 @@ import (
 	"bytes"
 	"fmt"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"time"
 
 	"github.com/rs/zerolog/log"
 
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
 )
 
 const (
@@ -46,7 +46,7 @@ func DoCurl(args []string) (err error) {
 		}
 	}()
 
-	cmd := exec.Command("curl", args...)
+	cmd := myexec.Command("curl", args...)
 	var stdout, stderr bytes.Buffer
 	cmd.Stdout = &stdout
 	cmd.Stderr = &stderr
@@ -60,11 +60,11 @@ func DoCurl(args []string) (err error) {
 		return
 	}
 	if stdout.String() != "" {
-		fmt.Printf(stdout.String())
+		fmt.Println(stdout.String())
 		curlResult.Result = stdout.String()
 		curlResult.ResultType = normalResult
 	} else if stderr.String() != "" {
-		fmt.Printf(stderr.String())
+		fmt.Println(stderr.String())
 		curlResult.ErrorMsg = stderr.String()
 		curlResult.ResultType = errorResult
 	}
diff --git a/hrp/internal/dial/dns.go b/hrp/internal/dial/dns.go
index 7f1e7e034..20a3f3d47 100644
--- a/hrp/internal/dial/dns.go
+++ b/hrp/internal/dial/dns.go
@@ -1,7 +1,6 @@
 package dial
 
 import (
-	"encoding/json"
 	"fmt"
 	"io/ioutil"
 	"net"
@@ -18,6 +17,7 @@ import (
 	"github.com/rs/zerolog/log"
 
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/internal/json"
 )
 
 const (
diff --git a/hrp/internal/dial/traceroute_unix.go b/hrp/internal/dial/traceroute_unix.go
index b66215920..527426664 100644
--- a/hrp/internal/dial/traceroute_unix.go
+++ b/hrp/internal/dial/traceroute_unix.go
@@ -1,5 +1,4 @@
 //go:build darwin || linux
-// +build darwin linux
 
 package dial
 
@@ -8,7 +7,6 @@ import (
 	"fmt"
 	"net/url"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"regexp"
 	"strconv"
@@ -19,6 +17,7 @@ import (
 	"github.com/rs/zerolog/log"
 
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
 )
 
 var (
@@ -52,7 +51,7 @@ func DoTraceRoute(traceRouteOptions *TraceRouteOptions, args []string) (err erro
 		traceRouteTarget = strings.Split(parsedURL.Host, ":")[0]
 	}
 
-	cmd := exec.Command("traceroute", "-m", strconv.Itoa(traceRouteOptions.MaxTTL),
+	cmd := myexec.Command("traceroute", "-m", strconv.Itoa(traceRouteOptions.MaxTTL),
 		"-q", strconv.Itoa(traceRouteOptions.Queries), traceRouteTarget)
 	stdout, _ := cmd.StdoutPipe()
 
diff --git a/hrp/internal/dial/traceroute_windows.go b/hrp/internal/dial/traceroute_windows.go
index a1b4b37b9..b80d199ea 100644
--- a/hrp/internal/dial/traceroute_windows.go
+++ b/hrp/internal/dial/traceroute_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package dial
 
@@ -8,7 +7,6 @@ import (
 	"fmt"
 	"net/url"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"regexp"
 	"strconv"
@@ -19,6 +17,7 @@ import (
 	"github.com/rs/zerolog/log"
 
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
 )
 
 var (
@@ -50,7 +49,7 @@ func DoTraceRoute(traceRouteOptions *TraceRouteOptions, args []string) (err erro
 		traceRouteTarget = strings.Split(parsedURL.Host, ":")[0]
 	}
 
-	cmd := exec.Command("tracert", "-h", strconv.Itoa(traceRouteOptions.MaxTTL), traceRouteTarget)
+	cmd := myexec.Command("tracert", "-h", strconv.Itoa(traceRouteOptions.MaxTTL), traceRouteTarget)
 	stdout, _ := cmd.StdoutPipe()
 
 	startT := time.Now()
diff --git a/hrp/internal/env/env.go b/hrp/internal/env/env.go
new file mode 100644
index 000000000..4fbb84e0d
--- /dev/null
+++ b/hrp/internal/env/env.go
@@ -0,0 +1,14 @@
+package env
+
+import "os"
+
+var (
+	WDA_USB_DRIVER = os.Getenv("WDA_USB_DRIVER")
+	VEDEM_OCR_URL  = os.Getenv("VEDEM_OCR_URL")
+	VEDEM_OCR_AK   = os.Getenv("VEDEM_OCR_AK")
+	VEDEM_OCR_SK   = os.Getenv("VEDEM_OCR_SK")
+	DISABLE_GA     = os.Getenv("DISABLE_GA")
+	DISABLE_SENTRY = os.Getenv("DISABLE_SENTRY")
+	PYPI_INDEX_URL = os.Getenv("PYPI_INDEX_URL")
+	PATH           = os.Getenv("PATH")
+)
diff --git a/hrp/internal/myexec/cmd.go b/hrp/internal/myexec/cmd.go
new file mode 100644
index 000000000..b92e312e0
--- /dev/null
+++ b/hrp/internal/myexec/cmd.go
@@ -0,0 +1,163 @@
+package myexec
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
+	"github.com/httprunner/httprunner/v4/hrp/internal/env"
+)
+
+var python3Executable string = "python3" // system default python3
+
+func isPython3(python string) bool {
+	out, err := Command(python, "--version").Output()
+	if err != nil {
+		return false
+	}
+	if strings.HasPrefix(string(out), "Python 3") {
+		return true
+	}
+	return false
+}
+
+// EnsurePython3Venv ensures python3 venv with specified packages
+// venv should be directory path of target venv
+func EnsurePython3Venv(venv string, packages ...string) (python3 string, err error) {
+	// priority: specified > $HOME/.hrp/venv
+	if venv == "" {
+		home, err := os.UserHomeDir()
+		if err != nil {
+			return "", errors.Wrap(err, "get user home dir failed")
+		}
+		venv = filepath.Join(home, ".hrp", "venv")
+	}
+	python3, err = ensurePython3Venv(venv, packages...)
+	if err != nil {
+		return "", errors.Wrap(code.InvalidPython3Venv, err.Error())
+	}
+	python3Executable = python3
+	log.Info().Str("Python3Executable", python3Executable).Msg("set python3 executable path")
+	return python3, nil
+}
+
+func ExecPython3Command(cmdName string, args ...string) error {
+	args = append([]string{"-m", cmdName}, args...)
+	return RunCommand(python3Executable, args...)
+}
+
+func AssertPythonPackage(python3 string, pkgName, pkgVersion string) error {
+	out, err := exec.Command(
+		python3, "-c", fmt.Sprintf("import %s; print(%s.__version__)", pkgName, pkgName),
+	).Output()
+	if err != nil {
+		return fmt.Errorf("python package %s not found", pkgName)
+	}
+
+	// do not check version if pkgVersion is empty
+	if pkgVersion == "" {
+		log.Info().Str("name", pkgName).Msg("python package is ready")
+		return nil
+	}
+
+	// check package version equality
+	version := strings.TrimSpace(string(out))
+	if strings.TrimLeft(version, "v") != strings.TrimLeft(pkgVersion, "v") {
+		return fmt.Errorf("python package %s version %s not matched, please upgrade to %s",
+			pkgName, version, pkgVersion)
+	}
+
+	log.Info().Str("name", pkgName).Str("version", pkgVersion).Msg("python package is ready")
+	return nil
+}
+
+func InstallPythonPackage(python3 string, pkg string) (err error) {
+	var pkgName, pkgVersion string
+	if strings.Contains(pkg, "==") {
+		// funppy==0.5.0
+		pkgInfo := strings.Split(pkg, "==")
+		pkgName = pkgInfo[0]
+		pkgVersion = pkgInfo[1]
+	} else {
+		// funppy
+		pkgName = pkg
+	}
+
+	// check if package installed and version matched
+	err = AssertPythonPackage(python3, pkgName, pkgVersion)
+	if err == nil {
+		return nil
+	}
+
+	// check if pip available
+	err = RunCommand(python3, "-m", "pip", "--version")
+	if err != nil {
+		log.Warn().Msg("pip is not available")
+		return errors.Wrap(err, "pip is not available")
+	}
+
+	log.Info().Str("pkgName", pkgName).Str("pkgVersion", pkgVersion).Msg("installing python package")
+
+	// install package
+	pypiIndexURL := env.PYPI_INDEX_URL
+	if pypiIndexURL == "" {
+		pypiIndexURL = "https://pypi.org/simple" // default
+	}
+	err = RunCommand(python3, "-m", "pip", "install", "--upgrade", pkg,
+		"--index-url", pypiIndexURL,
+		"--quiet", "--disable-pip-version-check")
+	if err != nil {
+		return errors.Wrap(err, "pip install package failed")
+	}
+
+	return AssertPythonPackage(python3, pkgName, pkgVersion)
+}
+
+func RunCommand(cmdName string, args ...string) error {
+	cmd := Command(cmdName, args...)
+	log.Info().Str("cmd", cmd.String()).Msg("exec command")
+
+	// add cmd dir path to $PATH
+	if cmdDir := filepath.Dir(cmdName); cmdDir != "" {
+		path := fmt.Sprintf("%s:%s", cmdDir, env.PATH)
+		if err := os.Setenv("PATH", path); err != nil {
+			log.Error().Err(err).Msg("set env $PATH failed")
+			return err
+		}
+	}
+
+	// print output with colors
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	err := cmd.Run()
+	if err != nil {
+		log.Error().Err(err).Msg("exec command failed")
+		return err
+	}
+
+	return nil
+}
+
+func ExecCommandInDir(cmd *exec.Cmd, dir string) error {
+	log.Info().Str("cmd", cmd.String()).Str("dir", dir).Msg("exec command")
+	cmd.Dir = dir
+
+	// print output with colors
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	err := cmd.Run()
+	if err != nil {
+		log.Error().Err(err).Msg("exec command failed")
+		return err
+	}
+
+	return nil
+}
diff --git a/hrp/internal/builtin/utils_unix.go b/hrp/internal/myexec/cmd_uixt.go
similarity index 53%
rename from hrp/internal/builtin/utils_unix.go
rename to hrp/internal/myexec/cmd_uixt.go
index 9c0feb2c0..39c640853 100644
--- a/hrp/internal/builtin/utils_unix.go
+++ b/hrp/internal/myexec/cmd_uixt.go
@@ -1,30 +1,18 @@
 //go:build darwin || linux
-// +build darwin linux
 
-package builtin
+package myexec
 
 import (
 	"fmt"
 	"os"
 	"os/exec"
 	"path/filepath"
-	"strings"
+	"syscall"
 
 	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
 )
 
-func isPython3(python string) bool {
-	out, err := exec.Command(python, "--version").Output()
-	if err != nil {
-		return false
-	}
-	if strings.HasPrefix(string(out), "Python 3") {
-		return true
-	}
-	return false
-}
-
 func getPython3Executable(venvDir string) string {
 	return filepath.Join(venvDir, "bin", "python3")
 }
@@ -41,20 +29,20 @@ func ensurePython3Venv(venv string, packages ...string) (python3 string, err err
 	if !isPython3(python3) {
 		// python3 venv not available, create one
 		// check if system python3 is available
-		if err := ExecCommand("python3", "--version"); err != nil {
+		if err := RunCommand("python3", "--version"); err != nil {
 			return "", errors.Wrap(err, "python3 not found")
 		}
 
 		// check if .venv exists
 		if _, err := os.Stat(venv); err == nil {
 			// .venv exists, remove first
-			if err := ExecCommand("rm", "-rf", venv); err != nil {
+			if err := RunCommand("rm", "-rf", venv); err != nil {
 				return "", errors.Wrap(err, "remove existed venv failed")
 			}
 		}
 
 		// create python3 .venv
-		if err := ExecCommand("python3", "-m", "venv", venv); err != nil {
+		if err := RunCommand("python3", "-m", "venv", venv); err != nil {
 			return "", errors.Wrap(err, "create python3 venv failed")
 		}
 	}
@@ -70,28 +58,14 @@ func ensurePython3Venv(venv string, packages ...string) (python3 string, err err
 	return python3, nil
 }
 
-func ExecCommand(cmdName string, args ...string) error {
-	cmd := exec.Command(cmdName, args...)
-	log.Info().Str("cmd", cmd.String()).Msg("exec command")
-
-	// add cmd dir path to $PATH
-	if cmdDir := filepath.Dir(cmdName); cmdDir != "" {
-		PATH := fmt.Sprintf("%s:%s", cmdDir, os.Getenv("PATH"))
-		if err := os.Setenv("PATH", PATH); err != nil {
-			log.Error().Err(err).Msg("set env $PATH failed")
-			return err
-		}
-	}
-
-	// print output with colors
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-
-	err := cmd.Run()
-	if err != nil {
-		log.Error().Err(err).Msg("exec command failed")
-		return err
+func Command(name string, arg ...string) *exec.Cmd {
+	cmd := exec.Command(name, arg...)
+	cmd.SysProcAttr = &syscall.SysProcAttr{
+		Setpgid: true,
 	}
+	return cmd
+}
 
-	return nil
+func KillProcessesByGpid(cmd *exec.Cmd) error {
+	return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
 }
diff --git a/hrp/internal/builtin/utils_windows.go b/hrp/internal/myexec/cmd_windows.go
similarity index 73%
rename from hrp/internal/builtin/utils_windows.go
rename to hrp/internal/myexec/cmd_windows.go
index 6c8ae13ed..3e202002e 100644
--- a/hrp/internal/builtin/utils_windows.go
+++ b/hrp/internal/myexec/cmd_windows.go
@@ -1,14 +1,15 @@
 //go:build windows
-// +build windows
 
-package builtin
+package myexec
 
 import (
 	"fmt"
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strconv"
 	"strings"
+	"syscall"
 
 	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
@@ -21,17 +22,6 @@ func init() {
 	}
 }
 
-func isPython3(python string) bool {
-	out, err := exec.Command("cmd", "/c", python, "--version").Output()
-	if err != nil {
-		return false
-	}
-	if strings.HasPrefix(string(out), "Python 3") {
-		return true
-	}
-	return false
-}
-
 func getPython3Executable(venvDir string) string {
 	python := filepath.Join(venvDir, "Scripts", "python3.exe")
 	if isPython3(python) {
@@ -64,7 +54,7 @@ func ensurePython3Venv(venvDir string, packages ...string) (python3 string, err
 		// check if .venv exists
 		if _, err := os.Stat(venvDir); err == nil {
 			// .venv exists, remove first
-			if err := ExecCommand("del", "/q", venvDir); err != nil {
+			if err := RunCommand("del", "/q", venvDir); err != nil {
 				return "", errors.Wrap(err, "remove existed venv failed")
 			}
 		}
@@ -72,10 +62,10 @@ func ensurePython3Venv(venvDir string, packages ...string) (python3 string, err
 		// create python3 .venv
 		// notice: --symlinks should be specified for windows
 		// https://github.com/actions/virtual-environments/issues/2690
-		if err := ExecCommand(systemPython, "-m", "venv", "--symlinks", venvDir); err != nil {
+		if err := RunCommand(systemPython, "-m", "venv", "--symlinks", venvDir); err != nil {
 			// fix: failed to symlink on Windows
 			log.Warn().Msg("failed to create python3 .venv by using --symlinks, try to use --copies")
-			if err := ExecCommand(systemPython, "-m", "venv", "--copies", venvDir); err != nil {
+			if err := RunCommand(systemPython, "-m", "venv", "--copies", venvDir); err != nil {
 				return "", errors.Wrap(err, "create python3 venv failed")
 			}
 		}
@@ -101,22 +91,18 @@ func ensurePython3Venv(venvDir string, packages ...string) (python3 string, err
 	return python3, nil
 }
 
-func ExecCommand(cmdName string, args ...string) error {
+func Command(name string, arg ...string) *exec.Cmd {
 	// "cmd /c" carries out the command specified by string and then stops
 	// refer: https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/cmd
-	cmdStr := fmt.Sprintf("%s %s", cmdName, strings.Join(args, " "))
-	cmd := exec.Command("cmd", "/c", cmdStr)
-	log.Info().Str("cmd", cmd.String()).Msg("exec command")
-
-	// print output with colors
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-
-	err := cmd.Run()
-	if err != nil {
-		log.Error().Err(err).Msg("exec command failed")
-		return err
+	cmd := exec.Command("cmd.exe")
+	cmd.SysProcAttr = &syscall.SysProcAttr{
+		CmdLine:    strings.Join(append([]string{"/c", name}, arg...), " "),
+		HideWindow: true,
 	}
+	return cmd
+}
 
-	return nil
+func KillProcessesByGpid(cmd *exec.Cmd) error {
+	killCmd := Command("taskkill", "/T", "/F", "/PID ", strconv.Itoa(cmd.Process.Pid))
+	return killCmd.Run()
 }
diff --git a/hrp/internal/scaffold/main.go b/hrp/internal/scaffold/main.go
index f27a035a8..13a816963 100644
--- a/hrp/internal/scaffold/main.go
+++ b/hrp/internal/scaffold/main.go
@@ -4,7 +4,6 @@ import (
 	"embed"
 	"fmt"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"time"
 
@@ -14,6 +13,7 @@ import (
 
 	"github.com/httprunner/httprunner/v4/hrp"
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
 	"github.com/httprunner/httprunner/v4/hrp/internal/sdk"
 	"github.com/httprunner/httprunner/v4/hrp/internal/version"
 )
@@ -177,7 +177,7 @@ func CreateScaffold(projectName string, pluginType PluginType, venv string, forc
 func createGoPlugin(projectName string) error {
 	log.Info().Msg("start to create hashicorp go plugin")
 	// check go sdk
-	if err := builtin.ExecCommandInDir(exec.Command("go", "version"), projectName); err != nil {
+	if err := myexec.RunCommand("go", "version"); err != nil {
 		return errors.Wrap(err, "go sdk not installed")
 	}
 
@@ -207,9 +207,9 @@ func createPythonPlugin(projectName, venv string) error {
 
 	packages := []string{
 		fmt.Sprintf("funppy==%s", fungo.Version),
-		fmt.Sprintf("httprunner==%s", version.VERSION),
+		fmt.Sprintf("httprunner==%s", version.HttpRunnerMinimumVersion),
 	}
-	_, err = builtin.EnsurePython3Venv(venv, packages...)
+	_, err = myexec.EnsurePython3Venv(venv, packages...)
 	if err != nil {
 		return err
 	}
diff --git a/hrp/internal/scaffold/templates/plugin/.debugtalk_gen.py b/hrp/internal/scaffold/templates/plugin/.debugtalk_gen.py
index 708b20f9d..d5c510157 100644
--- a/hrp/internal/scaffold/templates/plugin/.debugtalk_gen.py
+++ b/hrp/internal/scaffold/templates/plugin/.debugtalk_gen.py
@@ -1,4 +1,4 @@
-# NOTE: Generated By hrp v4.2.0, DO NOT EDIT!
+# NOTE: Generated By hrp v4.3.0, DO NOT EDIT!
 
 import sys
 import os
@@ -10,6 +10,7 @@
 
 if __name__ == "__main__":
     import funppy
+
     funppy.register("get_user_agent", get_user_agent)
     funppy.register("sleep", sleep)
     funppy.register("sum", sum)
diff --git a/hrp/internal/scaffold/templates/plugin/debugtalk_gen.go b/hrp/internal/scaffold/templates/plugin/debugtalk_gen.go
index fd479e843..9d08c9a03 100644
--- a/hrp/internal/scaffold/templates/plugin/debugtalk_gen.go
+++ b/hrp/internal/scaffold/templates/plugin/debugtalk_gen.go
@@ -1,4 +1,4 @@
-// NOTE: Generated By hrp v4.2.0, DO NOT EDIT!
+// NOTE: Generated By hrp v4.3.0, DO NOT EDIT!
 package main
 
 import (
diff --git a/hrp/internal/scaffold/templates/report/template.html b/hrp/internal/scaffold/templates/report/template.html
index 4bff6c653..0575c0217 100644
--- a/hrp/internal/scaffold/templates/report/template.html
+++ b/hrp/internal/scaffold/templates/report/template.html
@@ -338,14 +338,14 @@ <h3>Statistics:</h3>
                     </div>
                 </div>
             </div>
-            {{ if .Attachment }}
+            {{ if .Attachments }}
             <a class="button" href="#popup_attachment_{{$suite_index}}_{{$loop_index}}">traceback</a>
             <div id="popup_attachment_{{$suite_index}}_{{$loop_index}}" class="overlay">
                 <div class="popup">
                     <h2>Traceback Message</h2>
                     <a class="close" href="#record_{{$suite_index}}_{{$loop_index}}">&times;</a>
                     <div class="content">
-                        <pre>{{ .Attachment }}</pre>
+                        <pre>{{ .Attachments }}</pre>
                     </div>
                 </div>
             </div>
diff --git a/hrp/internal/sdk/init.go b/hrp/internal/sdk/init.go
index 8e69d5e22..ddb12087a 100644
--- a/hrp/internal/sdk/init.go
+++ b/hrp/internal/sdk/init.go
@@ -2,13 +2,13 @@ package sdk
 
 import (
 	"fmt"
-	"os"
 
 	"github.com/denisbrodbeck/machineid"
 	"github.com/getsentry/sentry-go"
 	"github.com/google/uuid"
 	"github.com/rs/zerolog/log"
 
+	"github.com/httprunner/httprunner/v4/hrp/internal/env"
 	"github.com/httprunner/httprunner/v4/hrp/internal/version"
 )
 
@@ -29,7 +29,7 @@ func init() {
 	gaClient = NewGAClient(trackingID, clientID)
 
 	// init sentry sdk
-	if os.Getenv("DISABLE_SENTRY") == "true" {
+	if env.DISABLE_SENTRY == "true" {
 		return
 	}
 	err = sentry.Init(sentry.ClientOptions{
@@ -50,7 +50,7 @@ func init() {
 }
 
 func SendEvent(e IEvent) error {
-	if os.Getenv("DISABLE_GA") == "true" {
+	if env.DISABLE_GA == "true" {
 		// do not send GA events in CI environment
 		return nil
 	}
diff --git a/hrp/internal/version/VERSION b/hrp/internal/version/VERSION
index 15a2b33b2..1ddc0f604 100644
--- a/hrp/internal/version/VERSION
+++ b/hrp/internal/version/VERSION
@@ -1,1 +1,1 @@
-v4.2.0
\ No newline at end of file
+v4.3.0
\ No newline at end of file
diff --git a/hrp/internal/version/init.go b/hrp/internal/version/init.go
index 4887463b9..d1f1513e7 100644
--- a/hrp/internal/version/init.go
+++ b/hrp/internal/version/init.go
@@ -6,3 +6,6 @@ import (
 
 //go:embed VERSION
 var VERSION string
+
+// httprunner python version
+const HttpRunnerMinimumVersion = "v4.2.0"
diff --git a/hrp/internal/wiki/main.go b/hrp/internal/wiki/main.go
index 108edca60..2557e4993 100644
--- a/hrp/internal/wiki/main.go
+++ b/hrp/internal/wiki/main.go
@@ -1,10 +1,9 @@
 package wiki
 
 import (
-	"os/exec"
-
 	"github.com/rs/zerolog/log"
 
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
 	"github.com/httprunner/httprunner/v4/hrp/internal/sdk"
 )
 
@@ -14,5 +13,5 @@ func OpenWiki() error {
 		Action:   "hrp wiki",
 	})
 	log.Info().Msgf("%s https://httprunner.com", openCmd)
-	return exec.Command(openCmd, "https://httprunner.com").Run()
+	return myexec.RunCommand(openCmd, "https://httprunner.com")
 }
diff --git a/hrp/loader.go b/hrp/loader.go
new file mode 100644
index 000000000..ff737c6d4
--- /dev/null
+++ b/hrp/loader.go
@@ -0,0 +1,68 @@
+package hrp
+
+import (
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+)
+
+func LoadTestCases(iTestCases ...ITestCase) ([]*TestCase, error) {
+	testCases := make([]*TestCase, 0)
+
+	for _, iTestCase := range iTestCases {
+		if _, ok := iTestCase.(*TestCase); ok {
+			testcase, err := iTestCase.ToTestCase()
+			if err != nil {
+				log.Error().Err(err).Msg("failed to convert ITestCase interface to TestCase struct")
+				return nil, err
+			}
+			testCases = append(testCases, testcase)
+			continue
+		}
+
+		// iTestCase should be a TestCasePath, file path or folder path
+		tcPath, ok := iTestCase.(*TestCasePath)
+		if !ok {
+			return nil, errors.New("invalid iTestCase type")
+		}
+
+		casePath := tcPath.GetPath()
+		err := fs.WalkDir(os.DirFS(casePath), ".", func(path string, dir fs.DirEntry, e error) error {
+			if dir == nil {
+				// casePath is a file other than a dir
+				path = casePath
+			} else if dir.IsDir() && path != "." && strings.HasPrefix(path, ".") {
+				// skip hidden folders
+				return fs.SkipDir
+			} else {
+				// casePath is a dir
+				path = filepath.Join(casePath, path)
+			}
+
+			// ignore non-testcase files
+			ext := filepath.Ext(path)
+			if ext != ".yml" && ext != ".yaml" && ext != ".json" {
+				return nil
+			}
+
+			// filtered testcases
+			testCasePath := TestCasePath(path)
+			tc, err := testCasePath.ToTestCase()
+			if err != nil {
+				return nil
+			}
+			testCases = append(testCases, tc)
+			return nil
+		})
+		if err != nil {
+			return nil, errors.Wrap(err, "read dir failed")
+		}
+	}
+
+	log.Info().Int("count", len(testCases)).Msg("load testcases successfully")
+	return testCases, nil
+}
diff --git a/hrp/parser.go b/hrp/parser.go
index 99e4359b6..34fb4f73b 100644
--- a/hrp/parser.go
+++ b/hrp/parser.go
@@ -13,9 +13,11 @@ import (
 	"github.com/httprunner/funplugin"
 	"github.com/httprunner/funplugin/shared"
 	"github.com/maja42/goval"
+	"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"
 )
 
 func newParser() *Parser {
@@ -123,14 +125,19 @@ func (p *Parser) Parse(raw interface{}, variablesMapping map[string]interface{})
 	}
 }
 
-func parseJSONNumber(raw builtinJSON.Number) (interface{}, error) {
+func parseJSONNumber(raw builtinJSON.Number) (value interface{}, err error) {
 	if strings.Contains(raw.String(), ".") {
 		// float64
-		return raw.Float64()
+		value, err = raw.Float64()
 	} else {
 		// int64
-		return raw.Int64()
+		value, err = raw.Int64()
 	}
+	if err != nil {
+		return nil, errors.Wrap(code.ParseError,
+			fmt.Sprintf("parse json number failed: %v", err))
+	}
+	return value, nil
 }
 
 const (
@@ -183,18 +190,18 @@ func (p *Parser) ParseString(raw string, variablesMapping map[string]interface{}
 			argsStr := funcMatched[2]
 			arguments, err := parseFunctionArguments(argsStr)
 			if err != nil {
-				return raw, err
+				return raw, errors.Wrap(code.ParseFunctionError, err.Error())
 			}
 			parsedArgs, err := p.Parse(arguments, variablesMapping)
 			if err != nil {
 				return raw, err
 			}
 
-			result, err := p.CallFunc(funcName, parsedArgs.([]interface{})...)
+			result, err := p.callFunc(funcName, parsedArgs.([]interface{})...)
 			if err != nil {
 				log.Error().Str("funcName", funcName).Interface("arguments", arguments).
 					Err(err).Msg("call function failed")
-				return raw, err
+				return raw, errors.Wrap(code.CallFunctionError, err.Error())
 			}
 			log.Info().Str("funcName", funcName).Interface("arguments", arguments).
 				Interface("output", result).Msg("call function success")
@@ -226,7 +233,8 @@ func (p *Parser) ParseString(raw string, variablesMapping map[string]interface{}
 			}
 			varValue, ok := variablesMapping[varName]
 			if !ok {
-				return raw, fmt.Errorf("variable %s not found", varName)
+				return raw, errors.Wrap(code.VariableNotFound,
+					fmt.Sprintf("variable %s not found", varName))
 			}
 
 			if fmt.Sprintf("${%s}", varName) == raw || fmt.Sprintf("$%s", varName) == raw {
@@ -251,9 +259,9 @@ func (p *Parser) ParseString(raw string, variablesMapping map[string]interface{}
 	return parsedString, nil
 }
 
-// CallFunc calls function with arguments
+// callFunc calls function with arguments
 // only support return at most one result value
-func (p *Parser) CallFunc(funcName string, arguments ...interface{}) (interface{}, error) {
+func (p *Parser) callFunc(funcName string, arguments ...interface{}) (interface{}, error) {
 	// call with plugin function
 	if p.plugin != nil {
 		if p.plugin.Has(funcName) {
@@ -428,7 +436,8 @@ func (p *Parser) ParseVariables(variables map[string]interface{}) (map[string]in
 			// variables = {"key": ["$key", 2]}
 			if _, ok := extractVarsSet[varName]; ok {
 				log.Error().Interface("variables", variables).Msg("[parseVariables] variable self reference error")
-				return variables, fmt.Errorf("variable self reference: %v", varName)
+				return variables, errors.Wrap(code.ParseVariablesError,
+					fmt.Sprintf("variable self reference: %v", varName))
 			}
 
 			// check if reference variable not in variables mapping
@@ -443,7 +452,8 @@ func (p *Parser) ParseVariables(variables map[string]interface{}) (map[string]in
 			}
 			if len(undefinedVars) > 0 {
 				log.Error().Interface("undefinedVars", undefinedVars).Msg("[parseVariables] variable not defined error")
-				return variables, fmt.Errorf("variable not defined: %v", undefinedVars)
+				return variables, errors.Wrap(code.ParseVariablesError,
+					fmt.Sprintf("variable not defined: %v", undefinedVars))
 			}
 
 			parsedValue, err := p.Parse(varValue, parsedVariables)
@@ -456,7 +466,7 @@ func (p *Parser) ParseVariables(variables map[string]interface{}) (map[string]in
 		// check if circular reference exists
 		if traverseRounds > len(variables) {
 			log.Error().Msg("[parseVariables] circular reference error, break infinite loop!")
-			return variables, fmt.Errorf("circular reference")
+			return variables, errors.Wrap(code.ParseVariablesError, "circular reference")
 		}
 	}
 
diff --git a/hrp/internal/boomer/README.md b/hrp/pkg/boomer/README.md
similarity index 100%
rename from hrp/internal/boomer/README.md
rename to hrp/pkg/boomer/README.md
diff --git a/hrp/internal/boomer/boomer.go b/hrp/pkg/boomer/boomer.go
similarity index 100%
rename from hrp/internal/boomer/boomer.go
rename to hrp/pkg/boomer/boomer.go
index cdf8e708f..baac35e3c 100644
--- a/hrp/internal/boomer/boomer.go
+++ b/hrp/pkg/boomer/boomer.go
@@ -1,8 +1,6 @@
 package boomer
 
 import (
-	"github.com/httprunner/httprunner/v4/hrp/internal/json"
-	"golang.org/x/net/context"
 	"math"
 	"os"
 	"os/signal"
@@ -11,6 +9,9 @@ import (
 
 	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
+	"golang.org/x/net/context"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/json"
 )
 
 // Mode is the running mode of boomer, both standalone and distributed are supported.
@@ -156,7 +157,6 @@ func NewWorkerBoomer(masterHost string, masterPort int) *Boomer {
 // SetAutoStart auto start to load testing
 func (b *Boomer) SetAutoStart() {
 	b.masterRunner.autoStart = true
-
 }
 
 // RunMaster start to run master runner
diff --git a/hrp/internal/boomer/client_grpc.go b/hrp/pkg/boomer/client_grpc.go
similarity index 96%
rename from hrp/internal/boomer/client_grpc.go
rename to hrp/pkg/boomer/client_grpc.go
index 82d4241b6..67484003f 100644
--- a/hrp/internal/boomer/client_grpc.go
+++ b/hrp/pkg/boomer/client_grpc.go
@@ -8,6 +8,8 @@ import (
 	"sync/atomic"
 	"time"
 
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
 	"golang.org/x/oauth2"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/backoff"
@@ -15,10 +17,8 @@ import (
 	"google.golang.org/grpc/credentials/oauth"
 	"google.golang.org/grpc/metadata"
 
-	"github.com/httprunner/httprunner/v4/hrp/internal/boomer/data"
-	"github.com/httprunner/httprunner/v4/hrp/internal/boomer/grpc/messager"
-	"github.com/pkg/errors"
-	"github.com/rs/zerolog/log"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/boomer/data"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/boomer/grpc/messager"
 )
 
 type grpcClient struct {
@@ -247,7 +247,7 @@ func (c *grpcClient) recv() {
 			msg, err := c.config.getBiStreamClient().Recv()
 			if err != nil {
 				time.Sleep(1 * time.Second)
-				//log.Error().Err(err).Msg("failed to get message")
+				// log.Error().Err(err).Msg("failed to get message")
 				continue
 			}
 			if msg == nil {
@@ -317,7 +317,7 @@ func (c *grpcClient) sendMessage(msg *genericMessage) {
 		atomic.StoreInt32(&c.failCount, 0)
 		return
 	}
-	//log.Error().Err(err).Interface("genericMessage", *msg).Msg("failed to send message")
+	// log.Error().Err(err).Interface("genericMessage", *msg).Msg("failed to send message")
 	if msg.Type == "heartbeat" {
 		atomic.AddInt32(&c.failCount, 1)
 	}
diff --git a/hrp/internal/boomer/data/data.go b/hrp/pkg/boomer/data/data.go
similarity index 99%
rename from hrp/internal/boomer/data/data.go
rename to hrp/pkg/boomer/data/data.go
index 9e0a21eae..bdb1f48cf 100644
--- a/hrp/internal/boomer/data/data.go
+++ b/hrp/pkg/boomer/data/data.go
@@ -38,7 +38,6 @@ func init() {
 	}
 	hrpPath = filepath.Join(home, ".hrp")
 	_ = builtin.EnsureFolderExists(filepath.Join(hrpPath, "x509"))
-
 }
 
 // Path returns the absolute path the given relative file or directory path
diff --git a/hrp/internal/boomer/data/x509/README.md b/hrp/pkg/boomer/data/x509/README.md
similarity index 100%
rename from hrp/internal/boomer/data/x509/README.md
rename to hrp/pkg/boomer/data/x509/README.md
diff --git a/hrp/internal/boomer/data/x509/ca_cert.pem b/hrp/pkg/boomer/data/x509/ca_cert.pem
similarity index 100%
rename from hrp/internal/boomer/data/x509/ca_cert.pem
rename to hrp/pkg/boomer/data/x509/ca_cert.pem
diff --git a/hrp/internal/boomer/data/x509/ca_key.pem b/hrp/pkg/boomer/data/x509/ca_key.pem
similarity index 100%
rename from hrp/internal/boomer/data/x509/ca_key.pem
rename to hrp/pkg/boomer/data/x509/ca_key.pem
diff --git a/hrp/internal/boomer/data/x509/client_ca_cert.pem b/hrp/pkg/boomer/data/x509/client_ca_cert.pem
similarity index 100%
rename from hrp/internal/boomer/data/x509/client_ca_cert.pem
rename to hrp/pkg/boomer/data/x509/client_ca_cert.pem
diff --git a/hrp/internal/boomer/data/x509/client_ca_key.pem b/hrp/pkg/boomer/data/x509/client_ca_key.pem
similarity index 100%
rename from hrp/internal/boomer/data/x509/client_ca_key.pem
rename to hrp/pkg/boomer/data/x509/client_ca_key.pem
diff --git a/hrp/internal/boomer/data/x509/client_cert.pem b/hrp/pkg/boomer/data/x509/client_cert.pem
similarity index 100%
rename from hrp/internal/boomer/data/x509/client_cert.pem
rename to hrp/pkg/boomer/data/x509/client_cert.pem
diff --git a/hrp/internal/boomer/data/x509/client_key.pem b/hrp/pkg/boomer/data/x509/client_key.pem
similarity index 100%
rename from hrp/internal/boomer/data/x509/client_key.pem
rename to hrp/pkg/boomer/data/x509/client_key.pem
diff --git a/hrp/internal/boomer/data/x509/create.sh b/hrp/pkg/boomer/data/x509/create.sh
similarity index 100%
rename from hrp/internal/boomer/data/x509/create.sh
rename to hrp/pkg/boomer/data/x509/create.sh
diff --git a/hrp/internal/boomer/data/x509/openssl.cnf b/hrp/pkg/boomer/data/x509/openssl.cnf
similarity index 100%
rename from hrp/internal/boomer/data/x509/openssl.cnf
rename to hrp/pkg/boomer/data/x509/openssl.cnf
diff --git a/hrp/internal/boomer/data/x509/server_cert.pem b/hrp/pkg/boomer/data/x509/server_cert.pem
similarity index 100%
rename from hrp/internal/boomer/data/x509/server_cert.pem
rename to hrp/pkg/boomer/data/x509/server_cert.pem
diff --git a/hrp/internal/boomer/data/x509/server_key.pem b/hrp/pkg/boomer/data/x509/server_key.pem
similarity index 100%
rename from hrp/internal/boomer/data/x509/server_key.pem
rename to hrp/pkg/boomer/data/x509/server_key.pem
diff --git a/hrp/internal/boomer/grpc/messager/messager.pb.go b/hrp/pkg/boomer/grpc/messager/messager.pb.go
similarity index 96%
rename from hrp/internal/boomer/grpc/messager/messager.pb.go
rename to hrp/pkg/boomer/grpc/messager/messager.pb.go
index 66a201089..3ad177c55 100644
--- a/hrp/internal/boomer/grpc/messager/messager.pb.go
+++ b/hrp/pkg/boomer/grpc/messager/messager.pb.go
@@ -7,10 +7,11 @@
 package messager
 
 import (
-	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
-	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
 	reflect "reflect"
 	sync "sync"
+
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
 )
 
 const (
@@ -458,17 +459,20 @@ func file_grpc_proto_messager_proto_rawDescGZIP() []byte {
 	return file_grpc_proto_messager_proto_rawDescData
 }
 
-var file_grpc_proto_messager_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
-var file_grpc_proto_messager_proto_goTypes = []interface{}{
-	(*StreamRequest)(nil),    // 0: message.StreamRequest
-	(*StreamResponse)(nil),   // 1: message.StreamResponse
-	(*RegisterRequest)(nil),  // 2: message.RegisterRequest
-	(*RegisterResponse)(nil), // 3: message.RegisterResponse
-	(*SignOutRequest)(nil),   // 4: message.SignOutRequest
-	(*SignOutResponse)(nil),  // 5: message.SignOutResponse
-	nil,                      // 6: message.StreamRequest.DataEntry
-	nil,                      // 7: message.StreamResponse.DataEntry
-}
+var (
+	file_grpc_proto_messager_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
+	file_grpc_proto_messager_proto_goTypes  = []interface{}{
+		(*StreamRequest)(nil),    // 0: message.StreamRequest
+		(*StreamResponse)(nil),   // 1: message.StreamResponse
+		(*RegisterRequest)(nil),  // 2: message.RegisterRequest
+		(*RegisterResponse)(nil), // 3: message.RegisterResponse
+		(*SignOutRequest)(nil),   // 4: message.SignOutRequest
+		(*SignOutResponse)(nil),  // 5: message.SignOutResponse
+		nil,                      // 6: message.StreamRequest.DataEntry
+		nil,                      // 7: message.StreamResponse.DataEntry
+	}
+)
+
 var file_grpc_proto_messager_proto_depIdxs = []int32{
 	6, // 0: message.StreamRequest.data:type_name -> message.StreamRequest.DataEntry
 	7, // 1: message.StreamResponse.data:type_name -> message.StreamResponse.DataEntry
diff --git a/hrp/internal/boomer/grpc/messager/messager_grpc.pb.go b/hrp/pkg/boomer/grpc/messager/messager_grpc.pb.go
similarity index 99%
rename from hrp/internal/boomer/grpc/messager/messager_grpc.pb.go
rename to hrp/pkg/boomer/grpc/messager/messager_grpc.pb.go
index b4bbad606..80e1a6b50 100644
--- a/hrp/internal/boomer/grpc/messager/messager_grpc.pb.go
+++ b/hrp/pkg/boomer/grpc/messager/messager_grpc.pb.go
@@ -8,6 +8,7 @@ package messager
 
 import (
 	context "context"
+
 	grpc "google.golang.org/grpc"
 	codes "google.golang.org/grpc/codes"
 	status "google.golang.org/grpc/status"
@@ -95,15 +96,16 @@ type MessageServer interface {
 }
 
 // UnimplementedMessageServer must be embedded to have forward compatible implementations.
-type UnimplementedMessageServer struct {
-}
+type UnimplementedMessageServer struct{}
 
 func (UnimplementedMessageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
 	return nil, status.Errorf(codes.Unimplemented, "method Register not implemented")
 }
+
 func (UnimplementedMessageServer) SignOut(context.Context, *SignOutRequest) (*SignOutResponse, error) {
 	return nil, status.Errorf(codes.Unimplemented, "method SignOut not implemented")
 }
+
 func (UnimplementedMessageServer) BidirectionalStreamingMessage(Message_BidirectionalStreamingMessageServer) error {
 	return status.Errorf(codes.Unimplemented, "method BidirectionalStreamingMessage not implemented")
 }
diff --git a/hrp/internal/boomer/grpc/proto/messager.proto b/hrp/pkg/boomer/grpc/proto/messager.proto
similarity index 100%
rename from hrp/internal/boomer/grpc/proto/messager.proto
rename to hrp/pkg/boomer/grpc/proto/messager.proto
diff --git a/hrp/internal/boomer/message.go b/hrp/pkg/boomer/message.go
similarity index 100%
rename from hrp/internal/boomer/message.go
rename to hrp/pkg/boomer/message.go
diff --git a/hrp/internal/boomer/output.go b/hrp/pkg/boomer/output.go
similarity index 100%
rename from hrp/internal/boomer/output.go
rename to hrp/pkg/boomer/output.go
diff --git a/hrp/internal/boomer/ratelimiter.go b/hrp/pkg/boomer/ratelimiter.go
similarity index 100%
rename from hrp/internal/boomer/ratelimiter.go
rename to hrp/pkg/boomer/ratelimiter.go
diff --git a/hrp/internal/boomer/runner.go b/hrp/pkg/boomer/runner.go
similarity index 99%
rename from hrp/internal/boomer/runner.go
rename to hrp/pkg/boomer/runner.go
index 506e3dc52..d9e5d5f43 100644
--- a/hrp/internal/boomer/runner.go
+++ b/hrp/pkg/boomer/runner.go
@@ -10,13 +10,13 @@ import (
 	"sync/atomic"
 	"time"
 
-	"github.com/go-errors/errors"
 	"github.com/jinzhu/copier"
 	"github.com/olekukonko/tablewriter"
+	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
 
-	"github.com/httprunner/httprunner/v4/hrp/internal/boomer/grpc/messager"
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/boomer/grpc/messager"
 )
 
 const (
diff --git a/hrp/internal/boomer/server_grpc.go b/hrp/pkg/boomer/server_grpc.go
similarity index 99%
rename from hrp/internal/boomer/server_grpc.go
rename to hrp/pkg/boomer/server_grpc.go
index 2a9c9d00a..f795cac57 100644
--- a/hrp/internal/boomer/server_grpc.go
+++ b/hrp/pkg/boomer/server_grpc.go
@@ -9,6 +9,7 @@ import (
 	"sync/atomic"
 	"time"
 
+	"github.com/rs/zerolog/log"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/credentials"
@@ -17,9 +18,8 @@ import (
 	"google.golang.org/grpc/reflection"
 	"google.golang.org/grpc/status"
 
-	"github.com/httprunner/httprunner/v4/hrp/internal/boomer/data"
-	"github.com/httprunner/httprunner/v4/hrp/internal/boomer/grpc/messager"
-	"github.com/rs/zerolog/log"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/boomer/data"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/boomer/grpc/messager"
 )
 
 type WorkerNode struct {
diff --git a/hrp/internal/boomer/stats.go b/hrp/pkg/boomer/stats.go
similarity index 100%
rename from hrp/internal/boomer/stats.go
rename to hrp/pkg/boomer/stats.go
diff --git a/hrp/internal/boomer/task.go b/hrp/pkg/boomer/task.go
similarity index 100%
rename from hrp/internal/boomer/task.go
rename to hrp/pkg/boomer/task.go
diff --git a/hrp/internal/boomer/ulimit.go b/hrp/pkg/boomer/ulimit.go
similarity index 97%
rename from hrp/internal/boomer/ulimit.go
rename to hrp/pkg/boomer/ulimit.go
index 40f0c0cc1..bc62a218e 100644
--- a/hrp/internal/boomer/ulimit.go
+++ b/hrp/pkg/boomer/ulimit.go
@@ -1,5 +1,4 @@
 //go:build !windows
-// +build !windows
 
 package boomer
 
diff --git a/hrp/internal/boomer/ulimit_windows.go b/hrp/pkg/boomer/ulimit_windows.go
similarity index 91%
rename from hrp/internal/boomer/ulimit_windows.go
rename to hrp/pkg/boomer/ulimit_windows.go
index d02840dce..8641b1113 100644
--- a/hrp/internal/boomer/ulimit_windows.go
+++ b/hrp/pkg/boomer/ulimit_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package boomer
 
diff --git a/hrp/internal/boomer/utils.go b/hrp/pkg/boomer/utils.go
similarity index 99%
rename from hrp/internal/boomer/utils.go
rename to hrp/pkg/boomer/utils.go
index 94fad13b6..f10a90d06 100644
--- a/hrp/internal/boomer/utils.go
+++ b/hrp/pkg/boomer/utils.go
@@ -11,7 +11,6 @@ import (
 	"time"
 
 	"github.com/google/uuid"
-
 	"github.com/rs/zerolog/log"
 	"github.com/shirou/gopsutil/cpu"
 	"github.com/shirou/gopsutil/mem"
diff --git a/hrp/internal/convert/README.md b/hrp/pkg/convert/README.md
similarity index 100%
rename from hrp/internal/convert/README.md
rename to hrp/pkg/convert/README.md
diff --git a/hrp/internal/convert/asset/flowgram.png b/hrp/pkg/convert/asset/flowgram.png
similarity index 100%
rename from hrp/internal/convert/asset/flowgram.png
rename to hrp/pkg/convert/asset/flowgram.png
diff --git a/hrp/internal/convert/converter.go b/hrp/pkg/convert/converter.go
similarity index 98%
rename from hrp/internal/convert/converter.go
rename to hrp/pkg/convert/converter.go
index 4c69296e5..cf5378695 100644
--- a/hrp/internal/convert/converter.go
+++ b/hrp/pkg/convert/converter.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/httprunner/httprunner/v4/hrp"
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
 	"github.com/httprunner/httprunner/v4/hrp/internal/sdk"
 )
 
@@ -212,7 +213,7 @@ func (c *TCaseConverter) ToPyTest() (string, error) {
 	}
 
 	args := append([]string{"make"}, jsonPath)
-	err = builtin.ExecPython3Command("httprunner", args...)
+	err = myexec.ExecPython3Command("httprunner", args...)
 	if err != nil {
 		return "", err
 	}
diff --git a/hrp/internal/convert/from_curl.go b/hrp/pkg/convert/from_curl.go
similarity index 100%
rename from hrp/internal/convert/from_curl.go
rename to hrp/pkg/convert/from_curl.go
diff --git a/hrp/internal/convert/from_har.go b/hrp/pkg/convert/from_har.go
similarity index 100%
rename from hrp/internal/convert/from_har.go
rename to hrp/pkg/convert/from_har.go
diff --git a/hrp/internal/convert/from_json.go b/hrp/pkg/convert/from_json.go
similarity index 100%
rename from hrp/internal/convert/from_json.go
rename to hrp/pkg/convert/from_json.go
diff --git a/hrp/internal/convert/from_postman.go b/hrp/pkg/convert/from_postman.go
similarity index 100%
rename from hrp/internal/convert/from_postman.go
rename to hrp/pkg/convert/from_postman.go
diff --git a/hrp/internal/convert/from_swagger.go b/hrp/pkg/convert/from_swagger.go
similarity index 100%
rename from hrp/internal/convert/from_swagger.go
rename to hrp/pkg/convert/from_swagger.go
diff --git a/hrp/internal/convert/from_yaml.go b/hrp/pkg/convert/from_yaml.go
similarity index 100%
rename from hrp/internal/convert/from_yaml.go
rename to hrp/pkg/convert/from_yaml.go
diff --git a/hrp/internal/httpstat/main.go b/hrp/pkg/httpstat/main.go
similarity index 98%
rename from hrp/internal/httpstat/main.go
rename to hrp/pkg/httpstat/main.go
index 492421932..8688bf255 100644
--- a/hrp/internal/httpstat/main.go
+++ b/hrp/pkg/httpstat/main.go
@@ -11,6 +11,7 @@ import (
 	"net/http/httptrace"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/fatih/color"
@@ -100,6 +101,8 @@ type Stat struct {
 
 	// connected network info
 	network, addr string
+
+	mux *sync.RWMutex // avoid data race
 }
 
 // Finish sets the time when reading response is done.
@@ -176,6 +179,7 @@ func (s *Stat) Print() {
 // WithHTTPStat is a wrapper of httptrace.WithClientTrace.
 // It records the time of each httptrace hooks.
 func WithHTTPStat(req *http.Request, s *Stat) context.Context {
+	s.mux = new(sync.RWMutex)
 	s.schema = req.URL.Scheme
 	return httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{
 		DNSStart: func(i httptrace.DNSStartInfo) {
@@ -228,6 +232,8 @@ func WithHTTPStat(req *http.Request, s *Stat) context.Context {
 		},
 
 		WroteRequest: func(info httptrace.WroteRequestInfo) {
+			s.mux.Lock()
+			defer s.mux.Unlock()
 			now := time.Now()
 			s.serverStart = now
 
@@ -259,6 +265,8 @@ func WithHTTPStat(req *http.Request, s *Stat) context.Context {
 		},
 
 		GotFirstResponseByte: func() {
+			s.mux.Lock()
+			defer s.mux.Unlock()
 			s.serverDone = time.Now()
 			s.ServerProcessing = s.serverDone.Sub(s.serverStart)
 			s.StartTransfer = s.serverDone.Sub(s.dnsStart)
diff --git a/hrp/pkg/uixt/README.md b/hrp/pkg/uixt/README.md
new file mode 100644
index 000000000..c422d5d60
--- /dev/null
+++ b/hrp/pkg/uixt/README.md
@@ -0,0 +1,51 @@
+# uixt
+
+From v4.3.0，HttpRunner will support mobile UI automation testing:
+
+- iOS: based on [appium/WebDriverAgent], with forked client library [electricbubble/gwda] in golang
+- Android: based on [appium-uiautomator2-server], with forked client library [electricbubble/guia2] in golang
+
+Some UI recognition algorithms are also introduced for both iOS and Android:
+
+- OpenCV: based on [OpenCV 4], with golang bindings [hybridgroup/gocv] and helper utils [electricbubble/gwda-ext-opencv]
+- OCR: based on OCR API service from [volcengine], other API service may be extended
+
+## Dependencies
+
+### OpenCV
+
+[OpenCV 4] should be pre-installed.
+
+You can install OpenCV 4.6.0 using Homebrew on macOS.
+
+```bash
+$ brew install opencv
+```
+
+You can get more installation introduction on [hybridgroup/gocv].
+
+### OCR
+
+OCR API is a paid service, you need to pre-purchase and configure the environment variables.
+
+- VEDEM_OCR_URL
+- VEDEM_OCR_AK
+- VEDEM_OCR_SK
+
+## Thanks
+
+This uixt module is initially forked from the following repos and made a lot of changes.
+
+- [electricbubble/gwda-ext-opencv]
+- [electricbubble/gwda]
+- [electricbubble/guia2]
+
+
+[electricbubble/gwda-ext-opencv]: https://github.com/electricbubble/gwda-ext-opencv
+[appium/WebDriverAgent]: https://github.com/appium/WebDriverAgent
+[electricbubble/gwda]: https://github.com/electricbubble/gwda
+[electricbubble/guia2]: https://github.com/electricbubble/guia2
+[OpenCV 4]: https://opencv.org/
+[hybridgroup/gocv]: https://github.com/hybridgroup/gocv
+[volcengine]: https://www.volcengine.com/product/text-recognition
+[appium-uiautomator2-server]: https://github.com/appium/appium-uiautomator2-server
diff --git a/hrp/pkg/uixt/android_action.go b/hrp/pkg/uixt/android_action.go
new file mode 100644
index 000000000..b80816146
--- /dev/null
+++ b/hrp/pkg/uixt/android_action.go
@@ -0,0 +1,158 @@
+package uixt
+
+import "strings"
+
+type touchGesture struct {
+	Touch PointF  `json:"touch"`
+	Time  float64 `json:"time"`
+}
+
+type TouchAction []touchGesture
+
+func NewTouchAction(cap ...int) *TouchAction {
+	if len(cap) == 0 || cap[0] <= 0 {
+		cap = []int{8}
+	}
+	tmp := make(TouchAction, 0, cap[0])
+	return &tmp
+}
+
+func (ta *TouchAction) Add(x, y int, startTime ...float64) *TouchAction {
+	return ta.AddFloat(float64(x), float64(y), startTime...)
+}
+
+func (ta *TouchAction) AddFloat(x, y float64, startTime ...float64) *TouchAction {
+	if len(startTime) == 0 {
+		var tmp float64 = 0
+		if len(*ta) != 0 {
+			g := (*ta)[len(*ta)-1]
+			tmp = g.Time + 0.05
+		}
+		startTime = []float64{tmp}
+	}
+	*ta = append(*ta, touchGesture{Touch: PointF{x, y}, Time: startTime[0]})
+	return ta
+}
+
+func (ta *TouchAction) AddPoint(point Point, startTime ...float64) *TouchAction {
+	return ta.AddFloat(float64(point.X), float64(point.Y), startTime...)
+}
+
+func (ta *TouchAction) AddPointF(point PointF, startTime ...float64) *TouchAction {
+	return ta.AddFloat(point.X, point.Y, startTime...)
+}
+
+func (ud *uiaDriver) MultiPointerGesture(gesture1 *TouchAction, gesture2 *TouchAction, tas ...*TouchAction) (err error) {
+	// Must provide coordinates for at least 2 pointers
+	actions := make([]*TouchAction, 0)
+	actions = append(actions, gesture1, gesture2)
+	if len(tas) != 0 {
+		actions = append(actions, tas...)
+	}
+	data := map[string]interface{}{
+		"actions": actions,
+	}
+	// register(postHandler, new MultiPointerGesture("/wd/hub/session/:sessionId/touch/multi/perform"))
+	_, err = ud.httpPOST(data, "/session", ud.sessionId, "/touch/multi/perform")
+	return
+}
+
+type w3cGesture map[string]interface{}
+
+func _newW3CGesture() w3cGesture {
+	return make(w3cGesture)
+}
+
+func (g w3cGesture) _set(key string, value interface{}) w3cGesture {
+	g[key] = value
+	return g
+}
+
+func (g w3cGesture) pause(duration float64) w3cGesture {
+	return g._set("type", "pause").
+		_set("duration", duration)
+}
+
+func (g w3cGesture) keyDown(value string) w3cGesture {
+	return g._set("type", "keyDown").
+		_set("value", value)
+}
+
+func (g w3cGesture) keyUp(value string) w3cGesture {
+	return g._set("type", "keyUp").
+		_set("value", value)
+}
+
+func (g w3cGesture) pointerDown(button int) w3cGesture {
+	return g._set("type", "pointerDown")._set("button", button)
+}
+
+func (g w3cGesture) pointerUp(button int) w3cGesture {
+	return g._set("type", "pointerUp")._set("button", button)
+}
+
+func (g w3cGesture) pointerMove(x, y float64, origin string, duration float64, pressureAndSize ...float64) w3cGesture {
+	switch len(pressureAndSize) {
+	case 1:
+		g._set("pressure", pressureAndSize[0])
+	case 2:
+		g._set("pressure", pressureAndSize[0])
+		g._set("size", pressureAndSize[1])
+	}
+	return g._set("type", "pointerMove").
+		_set("duration", duration).
+		_set("origin", origin).
+		_set("x", x).
+		_set("y", y)
+}
+
+func (g w3cGesture) size(size ...float64) w3cGesture {
+	if len(size) == 0 {
+		size = []float64{1.0}
+	}
+	return g._set("size", size[0])
+}
+
+func (g w3cGesture) pressure(pressure ...float64) w3cGesture {
+	if len(pressure) == 0 {
+		pressure = []float64{1.0}
+	}
+	return g._set("pressure", pressure[0])
+}
+
+type W3CGestures []w3cGesture
+
+func NewW3CGestures(cap ...int) *W3CGestures {
+	if len(cap) == 0 || cap[0] <= 0 {
+		cap = []int{8}
+	}
+	tmp := make(W3CGestures, 0, cap[0])
+	return &tmp
+}
+
+func (g *W3CGestures) Pause(duration ...float64) *W3CGestures {
+	if len(duration) == 0 || duration[0] < 0 {
+		duration = []float64{0.5}
+	}
+	*g = append(*g, _newW3CGesture().pause(duration[0]*1000))
+	return g
+}
+
+func (g *W3CGestures) KeyDown(value string) *W3CGestures {
+	*g = append(*g, _newW3CGesture().keyDown(value))
+	return g
+}
+
+func (g *W3CGestures) KeyUp(value string) *W3CGestures {
+	*g = append(*g, _newW3CGesture().keyUp(value))
+	return g
+}
+
+func (g *W3CGestures) SendKeys(text string) *W3CGestures {
+	ss := strings.Split(text, "")
+	for i := range ss {
+		g.KeyDown(ss[i])
+		g.KeyUp(ss[i])
+	}
+	return g
+}
diff --git a/hrp/pkg/uixt/android_device.go b/hrp/pkg/uixt/android_device.go
new file mode 100644
index 000000000..492bf7658
--- /dev/null
+++ b/hrp/pkg/uixt/android_device.go
@@ -0,0 +1,689 @@
+package uixt
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"net"
+	"os/exec"
+	"reflect"
+	"strings"
+
+	"github.com/electricbubble/gadb"
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
+	"github.com/httprunner/httprunner/v4/hrp/internal/json"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
+)
+
+var (
+	AdbServerHost  = "localhost"
+	AdbServerPort  = gadb.AdbServerPort // 5037
+	UIA2ServerPort = 6790
+	DeviceTempPath = "/data/local/tmp"
+)
+
+const forwardToPrefix = "forward-to-"
+
+type AndroidDeviceOption func(*AndroidDevice)
+
+func WithSerialNumber(serial string) AndroidDeviceOption {
+	return func(device *AndroidDevice) {
+		device.SerialNumber = serial
+	}
+}
+
+func WithAdbIP(ip string) AndroidDeviceOption {
+	return func(device *AndroidDevice) {
+		device.IP = ip
+	}
+}
+
+func WithAdbPort(port int) AndroidDeviceOption {
+	return func(device *AndroidDevice) {
+		device.Port = port
+	}
+}
+
+func WithAdbLogOn(logOn bool) AndroidDeviceOption {
+	return func(device *AndroidDevice) {
+		device.LogOn = logOn
+	}
+}
+
+func GetAndroidDeviceOptions(dev *AndroidDevice) (deviceOptions []AndroidDeviceOption) {
+	if dev.SerialNumber != "" {
+		deviceOptions = append(deviceOptions, WithSerialNumber(dev.SerialNumber))
+	}
+	if dev.IP != "" {
+		deviceOptions = append(deviceOptions, WithAdbIP(dev.IP))
+	}
+	if dev.Port != 0 {
+		deviceOptions = append(deviceOptions, WithAdbPort(dev.Port))
+	}
+	if dev.LogOn {
+		deviceOptions = append(deviceOptions, WithAdbLogOn(true))
+	}
+	return
+}
+
+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))
+	}
+
+	device = &AndroidDevice{
+		Port: UIA2ServerPort,
+		IP:   AdbServerHost,
+	}
+	for _, option := range options {
+		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(serialNumber)
+		return device, nil
+	}
+
+	return nil, errors.Wrap(code.AndroidDeviceConnectionError,
+		fmt.Sprintf("device %s not found", device.SerialNumber))
+}
+
+func DeviceList() (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()
+}
+
+type AndroidDevice struct {
+	d            gadb.Device
+	logcat       *DeviceLogcat
+	SerialNumber string `json:"serial,omitempty" yaml:"serial,omitempty"`
+	IP           string `json:"ip,omitempty" yaml:"ip,omitempty"`
+	Port         int    `json:"port,omitempty" yaml:"port,omitempty"`
+	MjpegPort    int    `json:"mjpeg_port,omitempty" yaml:"mjpeg_port,omitempty"`
+	LogOn        bool   `json:"log_on,omitempty" yaml:"log_on,omitempty"`
+}
+
+func (dev *AndroidDevice) UUID() string {
+	return dev.SerialNumber
+}
+
+func (dev *AndroidDevice) NewDriver(capabilities Capabilities) (driverExt *DriverExt, err error) {
+	driver, err := dev.NewUSBDriver(capabilities)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to init UIA driver")
+	}
+
+	driverExt, err = Extend(driver)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to extend UIA Driver")
+	}
+
+	if dev.LogOn {
+		err = driverExt.Driver.StartCaptureLog("hrp_adb_log")
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	driverExt.UUID = dev.UUID()
+	return driverExt, err
+}
+
+// NewUSBDriver creates new client via USB connected device, this will also start a new session.
+// TODO: replace uiaDriver with WebDriver
+func (dev *AndroidDevice) NewUSBDriver(capabilities Capabilities) (driver *uiaDriver, err error) {
+	var localPort int
+	if localPort, err = getFreePort(); err != nil {
+		return nil, err
+	}
+	if err = dev.d.Forward(localPort, UIA2ServerPort); err != nil {
+		return nil, err
+	}
+
+	rawURL := fmt.Sprintf("http://%s%d:6790/wd/hub", forwardToPrefix, localPort)
+	driver, err = NewUIADriver(capabilities, rawURL)
+	if err != nil {
+		_ = dev.d.ForwardKill(localPort)
+		return nil, err
+	}
+	driver.adbDevice = dev.d
+	driver.logcat = dev.logcat
+	driver.localPort = localPort
+
+	return driver, nil
+}
+
+// NewHTTPDriver creates new remote HTTP client, this will also start a new session.
+// TODO: replace uiaDriver with WebDriver
+func (dev *AndroidDevice) NewHTTPDriver(capabilities Capabilities) (driver *uiaDriver, err error) {
+	rawURL := fmt.Sprintf("http://%s:%d/wd/hub", dev.IP, dev.Port)
+	if driver, err = NewUIADriver(capabilities, rawURL); err != nil {
+		return nil, err
+	}
+	driver.adbDevice = dev.d
+	return driver, nil
+}
+
+func getFreePort() (int, error) {
+	addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
+	if err != nil {
+		return 0, errors.Wrap(err, "resolve tcp addr failed")
+	}
+
+	l, err := net.ListenTCP("tcp", addr)
+	if err != nil {
+		return 0, errors.Wrap(err, "listen tcp addr failed")
+	}
+	defer func() { _ = l.Close() }()
+	return l.Addr().(*net.TCPAddr).Port, nil
+}
+
+type DeviceLogcat struct {
+	serial    string
+	logBuffer *bytes.Buffer
+	errs      []error
+	stopping  chan struct{}
+	done      chan struct{}
+	cmd       *exec.Cmd
+}
+
+func NewAdbLogcat(serial string) *DeviceLogcat {
+	return &DeviceLogcat{
+		serial:    serial,
+		logBuffer: new(bytes.Buffer),
+		stopping:  make(chan struct{}),
+		done:      make(chan struct{}),
+	}
+}
+
+// CatchLogcatContext starts logcat with timeout context
+func (l *DeviceLogcat) CatchLogcatContext(timeoutCtx context.Context) (err error) {
+	if err = l.CatchLogcat(); err != nil {
+		return
+	}
+	go func() {
+		select {
+		case <-timeoutCtx.Done():
+			_ = l.Stop()
+		case <-l.stopping:
+		}
+	}()
+	return
+}
+
+func (l *DeviceLogcat) Stop() error {
+	select {
+	case <-l.stopping:
+	default:
+		close(l.stopping)
+		<-l.done
+		close(l.done)
+	}
+	return l.Errors()
+}
+
+func (l *DeviceLogcat) Errors() (err error) {
+	for _, e := range l.errs {
+		if err != nil {
+			err = fmt.Errorf("%v |[DeviceLogcatErr] %v", err, e)
+		} else {
+			err = fmt.Errorf("[DeviceLogcatErr] %v", e)
+		}
+	}
+	return
+}
+
+func (l *DeviceLogcat) CatchLogcat() (err error) {
+	if l.cmd != nil {
+		log.Warn().Msg("logcat already start")
+		return nil
+	}
+
+	// clear logcat
+	if err = myexec.RunCommand("adb", "-s", l.serial, "logcat", "-c"); err != nil {
+		return
+	}
+
+	// start logcat
+	l.cmd = myexec.Command("adb", "-s", l.serial, "logcat", "-v", "time", "-s", "iesqaMonitor:V")
+	l.cmd.Stderr = l.logBuffer
+	l.cmd.Stdout = l.logBuffer
+	if err = l.cmd.Start(); err != nil {
+		return
+	}
+	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))
+		}
+		l.done <- struct{}{}
+	}()
+	return
+}
+
+func (l *DeviceLogcat) BufferedLogcat() (err error) {
+	// -d: dump the current buffered logcat result and exits
+	cmd := myexec.Command("adb", "-s", l.serial, "logcat", "-d")
+	cmd.Stdout = l.logBuffer
+	cmd.Stderr = l.logBuffer
+	if err = cmd.Run(); err != nil {
+		return
+	}
+	return
+}
+
+type ExportPoint struct {
+	Start     int         `json:"start" yaml:"start"`
+	End       int         `json:"end" yaml:"end"`
+	From      interface{} `json:"from" yaml:"from"`
+	To        interface{} `json:"to" yaml:"to"`
+	Operation string      `json:"operation" yaml:"operation"`
+	Ext       string      `json:"ext" yaml:"ext"`
+	RunTime   int         `json:"run_time,omitempty" yaml:"run_time,omitempty"`
+}
+
+func ConvertPoints(data string) (eps []ExportPoint) {
+	lines := strings.Split(data, "\n")
+	for _, line := range lines {
+		if strings.Contains(line, "ext") {
+			idx := strings.Index(line, "{")
+			line = line[idx:]
+			p := ExportPoint{}
+			err := json.Unmarshal([]byte(line), &p)
+			if err != nil {
+				log.Error().Msg("failed to parse point data")
+				continue
+			}
+			eps = append(eps, p)
+		}
+	}
+	return
+}
+
+type UiSelectorHelper struct {
+	value *bytes.Buffer
+}
+
+func NewUiSelectorHelper() UiSelectorHelper {
+	return UiSelectorHelper{value: bytes.NewBufferString("new UiSelector()")}
+}
+
+func (s UiSelectorHelper) String() string {
+	return s.value.String() + ";"
+}
+
+// Text Set the search criteria to match the visible text displayed
+// in a widget (for example, the text label to launch an app).
+//
+// The text for the element must match exactly with the string in your input
+// argument. Matching is case-sensitive.
+func (s UiSelectorHelper) Text(text string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.text("%s")`, text))
+	return s
+}
+
+// TextMatches Set the search criteria to match the visible text displayed in a layout
+// element, using a regular expression.
+//
+// The text in the widget must match exactly with the string in your
+// input argument.
+func (s UiSelectorHelper) TextMatches(regex string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.textMatches("%s")`, regex))
+	return s
+}
+
+// TextStartsWith Set the search criteria to match visible text in a widget that is
+// prefixed by the text parameter.
+//
+// The matching is case-insensitive.
+func (s UiSelectorHelper) TextStartsWith(text string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.textStartsWith("%s")`, text))
+	return s
+}
+
+// TextContains Set the search criteria to match the visible text in a widget
+// where the visible text must contain the string in your input argument.
+//
+// The matching is case-sensitive.
+func (s UiSelectorHelper) TextContains(text string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.textContains("%s")`, text))
+	return s
+}
+
+// ClassName Set the search criteria to match the class property
+// for a widget (for example, "android.widget.Button").
+func (s UiSelectorHelper) ClassName(className string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.className("%s")`, className))
+	return s
+}
+
+// ClassNameMatches Set the search criteria to match the class property
+// for a widget, using a regular expression.
+func (s UiSelectorHelper) ClassNameMatches(regex string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.classNameMatches("%s")`, regex))
+	return s
+}
+
+// Description Set the search criteria to match the content-description
+// property for a widget.
+//
+// The content-description is typically used
+// by the Android Accessibility framework to
+// provide an audio prompt for the widget when
+// the widget is selected. The content-description
+// for the widget must match exactly
+// with the string in your input argument.
+//
+// Matching is case-sensitive.
+func (s UiSelectorHelper) Description(desc string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.description("%s")`, desc))
+	return s
+}
+
+// DescriptionMatches Set the search criteria to match the content-description
+// property for a widget.
+//
+// The content-description is typically used
+// by the Android Accessibility framework to
+// provide an audio prompt for the widget when
+// the widget is selected. The content-description
+// for the widget must match exactly
+// with the string in your input argument.
+func (s UiSelectorHelper) DescriptionMatches(regex string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.descriptionMatches("%s")`, regex))
+	return s
+}
+
+// DescriptionStartsWith Set the search criteria to match the content-description
+// property for a widget.
+//
+// The content-description is typically used
+// by the Android Accessibility framework to
+// provide an audio prompt for the widget when
+// the widget is selected. The content-description
+// for the widget must start
+// with the string in your input argument.
+//
+// Matching is case-insensitive.
+func (s UiSelectorHelper) DescriptionStartsWith(desc string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.descriptionStartsWith("%s")`, desc))
+	return s
+}
+
+// DescriptionContains Set the search criteria to match the content-description
+// property for a widget.
+//
+// The content-description is typically used
+// by the Android Accessibility framework to
+// provide an audio prompt for the widget when
+// the widget is selected. The content-description
+// for the widget must contain
+// the string in your input argument.
+//
+// Matching is case-insensitive.
+func (s UiSelectorHelper) DescriptionContains(desc string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.descriptionContains("%s")`, desc))
+	return s
+}
+
+// ResourceId Set the search criteria to match the given resource ID.
+func (s UiSelectorHelper) ResourceId(id string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.resourceId("%s")`, id))
+	return s
+}
+
+// ResourceIdMatches Set the search criteria to match the resource ID
+// of the widget, using a regular expression.
+func (s UiSelectorHelper) ResourceIdMatches(regex string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.resourceIdMatches("%s")`, regex))
+	return s
+}
+
+// Index Set the search criteria to match the widget by its node
+// index in the layout hierarchy.
+//
+// The index value must be 0 or greater.
+//
+// Using the index can be unreliable and should only
+// be used as a last resort for matching. Instead,
+// consider using the `Instance(int)` method.
+func (s UiSelectorHelper) Index(index int) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.index(%d)`, index))
+	return s
+}
+
+// Instance Set the search criteria to match the
+// widget by its instance number.
+//
+// The instance value must be 0 or greater, where
+// the first instance is 0.
+//
+// For example, to simulate a user click on
+// the third image that is enabled in a UI screen, you
+// could specify a a search criteria where the instance is
+// 2, the `className(String)` matches the image
+// widget class, and `enabled(boolean)` is true.
+// The code would look like this:
+//  `new UiSelector().className("android.widget.ImageView")
+//    .enabled(true).instance(2);`
+func (s UiSelectorHelper) Instance(instance int) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.instance(%d)`, instance))
+	return s
+}
+
+// Enabled Set the search criteria to match widgets that are enabled.
+//
+// Typically, using this search criteria alone is not useful.
+// You should also include additional criteria, such as text,
+// content-description, or the class name for a widget.
+//
+// If no other search criteria is specified, and there is more
+// than one matching widget, the first widget in the tree
+// is selected.
+func (s UiSelectorHelper) Enabled(b bool) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.enabled(%t)`, b))
+	return s
+}
+
+// Focused Set the search criteria to match widgets that have focus.
+//
+// Typically, using this search criteria alone is not useful.
+// You should also include additional criteria, such as text,
+// content-description, or the class name for a widget.
+//
+// If no other search criteria is specified, and there is more
+// than one matching widget, the first widget in the tree
+// is selected.
+func (s UiSelectorHelper) Focused(b bool) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.focused(%t)`, b))
+	return s
+}
+
+// Focusable Set the search criteria to match widgets that are focusable.
+//
+// Typically, using this search criteria alone is not useful.
+// You should also include additional criteria, such as text,
+// content-description, or the class name for a widget.
+//
+// If no other search criteria is specified, and there is more
+// than one matching widget, the first widget in the tree
+// is selected.
+func (s UiSelectorHelper) Focusable(b bool) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.focusable(%t)`, b))
+	return s
+}
+
+// Scrollable Set the search criteria to match widgets that are scrollable.
+//
+// Typically, using this search criteria alone is not useful.
+// You should also include additional criteria, such as text,
+// content-description, or the class name for a widget.
+//
+// If no other search criteria is specified, and there is more
+// than one matching widget, the first widget in the tree
+// is selected.
+func (s UiSelectorHelper) Scrollable(b bool) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.scrollable(%t)`, b))
+	return s
+}
+
+// Selected Set the search criteria to match widgets that
+// are currently selected.
+//
+// Typically, using this search criteria alone is not useful.
+// You should also include additional criteria, such as text,
+// content-description, or the class name for a widget.
+//
+// If no other search criteria is specified, and there is more
+// than one matching widget, the first widget in the tree
+// is selected.
+func (s UiSelectorHelper) Selected(b bool) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.selected(%t)`, b))
+	return s
+}
+
+// Checked Set the search criteria to match widgets that
+// are currently checked (usually for checkboxes).
+//
+// Typically, using this search criteria alone is not useful.
+// You should also include additional criteria, such as text,
+// content-description, or the class name for a widget.
+//
+// If no other search criteria is specified, and there is more
+// than one matching widget, the first widget in the tree
+// is selected.
+func (s UiSelectorHelper) Checked(b bool) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.checked(%t)`, b))
+	return s
+}
+
+// Checkable Set the search criteria to match widgets that are checkable.
+//
+// Typically, using this search criteria alone is not useful.
+// You should also include additional criteria, such as text,
+// content-description, or the class name for a widget.
+//
+// If no other search criteria is specified, and there is more
+// than one matching widget, the first widget in the tree
+// is selected.
+func (s UiSelectorHelper) Checkable(b bool) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.checkable(%t)`, b))
+	return s
+}
+
+// Clickable Set the search criteria to match widgets that are clickable.
+//
+// Typically, using this search criteria alone is not useful.
+// You should also include additional criteria, such as text,
+// content-description, or the class name for a widget.
+//
+// If no other search criteria is specified, and there is more
+// than one matching widget, the first widget in the tree
+// is selected.
+func (s UiSelectorHelper) Clickable(b bool) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.clickable(%t)`, b))
+	return s
+}
+
+// LongClickable Set the search criteria to match widgets that are long-clickable.
+//
+// Typically, using this search criteria alone is not useful.
+// You should also include additional criteria, such as text,
+// content-description, or the class name for a widget.
+//
+// If no other search criteria is specified, and there is more
+// than one matching widget, the first widget in the tree
+// is selected.
+func (s UiSelectorHelper) LongClickable(b bool) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.longClickable(%t)`, b))
+	return s
+}
+
+// packageName Set the search criteria to match the package name
+// of the application that contains the widget.
+func (s UiSelectorHelper) packageName(name string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.packageName(%s)`, name))
+	return s
+}
+
+// PackageNameMatches Set the search criteria to match the package name
+// of the application that contains the widget.
+func (s UiSelectorHelper) PackageNameMatches(regex string) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.packageNameMatches(%s)`, regex))
+	return s
+}
+
+// ChildSelector Adds a child UiSelector criteria to this selector.
+//
+// Use this selector to narrow the search scope to
+// child widgets under a specific parent widget.
+func (s UiSelectorHelper) ChildSelector(selector UiSelectorHelper) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.childSelector(%s)`, selector.value.String()))
+	return s
+}
+
+func (s UiSelectorHelper) PatternSelector(selector UiSelectorHelper) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.patternSelector(%s)`, selector.value.String()))
+	return s
+}
+
+func (s UiSelectorHelper) ContainerSelector(selector UiSelectorHelper) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.containerSelector(%s)`, selector.value.String()))
+	return s
+}
+
+// FromParent Adds a child UiSelector criteria to this selector which is used to
+// start search from the parent widget.
+//
+// Use this selector to narrow the search scope to
+// sibling widgets as well all child widgets under a parent.
+func (s UiSelectorHelper) FromParent(selector UiSelectorHelper) UiSelectorHelper {
+	s.value.WriteString(fmt.Sprintf(`.fromParent(%s)`, selector.value.String()))
+	return s
+}
+
+type AndroidBySelector struct {
+	// Set the search criteria to match the given resource ResourceIdID.
+	ResourceIdID string `json:"id"`
+	// Set the search criteria to match the content-description property for a widget.
+	ContentDescription string `json:"accessibility id"`
+	XPath              string `json:"xpath"`
+	// Set the search criteria to match the class property for a widget (for example, "android.widget.Button").
+	ClassName   string `json:"class name"`
+	UiAutomator string `json:"-android uiautomator"`
+}
+
+func (by AndroidBySelector) getMethodAndSelector() (method, selector string) {
+	vBy := reflect.ValueOf(by)
+	tBy := reflect.TypeOf(by)
+	for i := 0; i < vBy.NumField(); i++ {
+		vi := vBy.Field(i).Interface()
+		// switch vi := vi.(type) {
+		// case string:
+		// 	selector = vi
+		// }
+		selector = vi.(string)
+		if selector != "" && selector != "UNKNOWN" {
+			method = tBy.Field(i).Tag.Get("json")
+			return
+		}
+	}
+	return
+}
diff --git a/hrp/pkg/uixt/android_driver.go b/hrp/pkg/uixt/android_driver.go
new file mode 100644
index 000000000..904b550fd
--- /dev/null
+++ b/hrp/pkg/uixt/android_driver.go
@@ -0,0 +1,985 @@
+package uixt
+
+import (
+	"bytes"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/electricbubble/gadb"
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
+)
+
+var errDriverNotImplemented = errors.New("driver method not implemented")
+
+type uiaDriver struct {
+	Driver
+
+	adbDevice gadb.Device
+	logcat    *DeviceLogcat
+	localPort int
+}
+
+func NewUIADriver(capabilities Capabilities, urlPrefix string) (driver *uiaDriver, err error) {
+	if capabilities == nil {
+		capabilities = NewCapabilities()
+	}
+	driver = new(uiaDriver)
+	if driver.urlPrefix, err = url.Parse(urlPrefix); err != nil {
+		return nil, err
+	}
+	var localPort int
+	{
+		tmpURL, _ := url.Parse(driver.urlPrefix.String())
+		hostname := tmpURL.Hostname()
+		if strings.HasPrefix(hostname, forwardToPrefix) {
+			localPort, _ = strconv.Atoi(strings.TrimPrefix(hostname, forwardToPrefix))
+		}
+	}
+	conn, err := net.Dial("tcp", fmt.Sprintf(":%d", localPort))
+	if err != nil {
+		return nil, fmt.Errorf("adb forward: %w", err)
+	}
+	driver.client = convertToHTTPClient(conn)
+	if session, err := driver.NewSession(capabilities); err != nil {
+		return nil, err
+	} else {
+		driver.sessionId = session.SessionId
+	}
+	return
+}
+
+type BatteryStatus int
+
+const (
+	_                                  = iota
+	BatteryStatusUnknown BatteryStatus = iota
+	BatteryStatusCharging
+	BatteryStatusDischarging
+	BatteryStatusNotCharging
+	BatteryStatusFull
+)
+
+func (bs BatteryStatus) String() string {
+	switch bs {
+	case BatteryStatusUnknown:
+		return "unknown"
+	case BatteryStatusCharging:
+		return "charging"
+	case BatteryStatusDischarging:
+		return "discharging"
+	case BatteryStatusNotCharging:
+		return "not charging"
+	case BatteryStatusFull:
+		return "full"
+	default:
+		return fmt.Sprintf("unknown status code (%d)", bs)
+	}
+}
+
+func (ud *uiaDriver) Close() (err error) {
+	if ud.sessionId == "" {
+		return nil
+	}
+	if _, err = ud.httpDELETE("/session", ud.sessionId); err == nil {
+		ud.sessionId = ""
+	}
+
+	return err
+}
+
+func (ud *uiaDriver) NewSession(capabilities Capabilities) (sessionInfo SessionInfo, err error) {
+	// register(postHandler, new NewSession("/wd/hub/session"))
+	var rawResp rawResponse
+	data := map[string]interface{}{"capabilities": capabilities}
+	if rawResp, err = ud.httpPOST(data, "/session"); err != nil {
+		return SessionInfo{SessionId: ""}, err
+	}
+	reply := new(struct{ Value struct{ SessionId string } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return SessionInfo{SessionId: ""}, err
+	}
+	sessionID := reply.Value.SessionId
+	// d.sessionIdCache[sessionID] = true
+	return SessionInfo{SessionId: sessionID}, nil
+}
+
+func (ud *uiaDriver) ActiveSession() (sessionInfo SessionInfo, err error) {
+	// [[FBRoute GET:@""] respondWithTarget:self action:@selector(handleGetActiveSession:)]
+	return SessionInfo{SessionId: ud.sessionId}, nil
+}
+
+func (ud *uiaDriver) SessionIDs() (sessionIDs []string, err error) {
+	// register(getHandler, new GetSessions("/wd/hub/sessions"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/sessions"); err != nil {
+		return nil, err
+	}
+	reply := new(struct{ Value []struct{ SessionId string } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+
+	sessionIDs = make([]string, len(reply.Value))
+	for i := range reply.Value {
+		sessionIDs[i] = reply.Value[i].SessionId
+	}
+	return
+}
+
+func (ud *uiaDriver) SessionDetails() (scrollData map[string]interface{}, err error) {
+	// register(getHandler, new GetSessionDetails("/wd/hub/session/:sessionId"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/session", ud.sessionId); err != nil {
+		return nil, err
+	}
+	reply := new(struct{ Value map[string]interface{} })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+
+	scrollData = reply.Value
+	return
+}
+
+func (ud *uiaDriver) DeleteSession() (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) Status() (deviceStatus DeviceStatus, err error) {
+	// register(getHandler, new Status("/wd/hub/status"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/status"); err != nil {
+		return DeviceStatus{Ready: false}, err
+	}
+	reply := new(struct {
+		Value struct {
+			// Message string
+			Ready bool
+		}
+	})
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return DeviceStatus{Ready: false}, err
+	}
+	return DeviceStatus{Ready: true}, nil
+}
+
+func (ud *uiaDriver) DeviceInfo() (deviceInfo DeviceInfo, err error) {
+	// register(getHandler, new GetDeviceInfo("/wd/hub/session/:sessionId/appium/device/info"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/session", ud.sessionId, "appium/device/info"); err != nil {
+		return DeviceInfo{}, err
+	}
+	reply := new(struct{ Value struct{ DeviceInfo } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return DeviceInfo{}, err
+	}
+	deviceInfo = reply.Value.DeviceInfo
+	return
+}
+
+func (ud *uiaDriver) Location() (location Location, err error) {
+	// TODO
+	return location, errDriverNotImplemented
+}
+
+func (ud *uiaDriver) BatteryInfo() (batteryInfo BatteryInfo, err error) {
+	// register(getHandler, new GetBatteryInfo("/wd/hub/session/:sessionId/appium/device/battery_info"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/session", ud.sessionId, "appium/device/battery_info"); err != nil {
+		return BatteryInfo{}, err
+	}
+	reply := new(struct{ Value struct{ BatteryInfo } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return BatteryInfo{}, err
+	}
+	if reply.Value.Level == -1 || reply.Value.Status == -1 {
+		return reply.Value.BatteryInfo, errors.New("cannot be retrieved from the system")
+	}
+	batteryInfo = reply.Value.BatteryInfo
+	return
+}
+
+func (ud *uiaDriver) WindowSize() (size Size, err error) {
+	// register(getHandler, new GetDeviceSize("/wd/hub/session/:sessionId/window/:windowHandle/size"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/session", ud.sessionId, "window/:windowHandle/size"); err != nil {
+		return Size{}, err
+	}
+	reply := new(struct{ Value struct{ Size } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return Size{}, err
+	}
+	size = reply.Value.Size
+	return
+}
+
+func (ud *uiaDriver) Screen() (screen Screen, err error) {
+	// TODO
+	return screen, errDriverNotImplemented
+}
+
+func (ud *uiaDriver) Scale() (scale float64, err error) {
+	return 1, nil
+}
+
+// PressBack simulates a short press on the BACK button.
+func (ud *uiaDriver) PressBack() (err error) {
+	// register(postHandler, new PressBack("/wd/hub/session/:sessionId/back"))
+	_, err = ud.httpPOST(nil, "/session", ud.sessionId, "back")
+	return
+}
+
+func (ud *uiaDriver) StartCamera() (err error) {
+	if _, err = ud.adbDevice.RunShellCommand("rm", "-r", "/sdcard/DCIM/Camera"); err != nil {
+		return err
+	}
+	time.Sleep(5 * time.Second)
+	var version string
+	if version, err = ud.adbDevice.RunShellCommand("getprop", "ro.build.version.release"); err != nil {
+		return err
+	}
+	if version == "11" || version == "12" {
+		if _, err = ud.adbDevice.RunShellCommand("am", "start", "-a", "android.media.action.STILL_IMAGE_CAMERA"); err != nil {
+			return err
+		}
+		time.Sleep(5 * time.Second)
+		if _, err = ud.adbDevice.RunShellCommand("input", "swipe", "750", "1000", "250", "1000"); err != nil {
+			return err
+		}
+		time.Sleep(5 * time.Second)
+		if _, err = ud.adbDevice.RunShellCommand("input", "keyevent", "27"); err != nil {
+			return err
+		}
+		return
+	} else {
+		if _, err = ud.adbDevice.RunShellCommand("am", "start", "-a", "android.media.action.VIDEO_CAPTURE"); err != nil {
+			return err
+		}
+		time.Sleep(5 * time.Second)
+		if _, err = ud.adbDevice.RunShellCommand("input", "keyevent", "27"); err != nil {
+			return err
+		}
+		return
+	}
+}
+
+func (ud *uiaDriver) StopCamera() (err error) {
+	err = ud.PressBack()
+	if err != nil {
+		return err
+	}
+	err = ud.Homescreen()
+	if err != nil {
+		return err
+	}
+
+	// kill samsung shell command
+	if _, err = ud.adbDevice.RunShellCommand("am", "force-stop", "com.sec.android.app.camera"); err != nil {
+		return err
+	}
+
+	// kill other camera (huawei mi)
+	if _, err = ud.adbDevice.RunShellCommand("am", "force-stop", "com.android.camera2"); err != nil {
+		return err
+	}
+	return
+}
+
+func (ud *uiaDriver) ActiveAppInfo() (info AppInfo, err error) {
+	// TODO
+	return info, errDriverNotImplemented
+}
+
+func (ud *uiaDriver) ActiveAppsList() (appsList []AppBaseInfo, err error) {
+	// TODO
+	return appsList, errDriverNotImplemented
+}
+
+func (ud *uiaDriver) AppState(bundleId string) (runState AppState, err error) {
+	// TODO
+	return runState, errDriverNotImplemented
+}
+
+func (ud *uiaDriver) IsLocked() (locked bool, err error) {
+	// TODO
+	return locked, errDriverNotImplemented
+}
+
+func (ud *uiaDriver) Unlock() (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) Lock() (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) Homescreen() (err error) {
+	return ud.PressKeyCode(KCHome, KMEmpty)
+}
+
+func (ud *uiaDriver) PressKeyCode(keyCode KeyCode, metaState KeyMeta, flags ...KeyFlag) (err error) {
+	if len(flags) == 0 {
+		flags = []KeyFlag{KFFromSystem}
+	}
+	return ud._pressKeyCode(keyCode, metaState, KFFromSystem)
+}
+
+func (ud *uiaDriver) _pressKeyCode(keyCode KeyCode, metaState KeyMeta, flags ...KeyFlag) (err error) {
+	// register(postHandler, new PressKeyCodeAsync("/wd/hub/session/:sessionId/appium/device/press_keycode"))
+	data := map[string]interface{}{
+		"keycode": keyCode,
+	}
+	if metaState != KMEmpty {
+		data["metastate"] = metaState
+	}
+	if len(flags) != 0 {
+		data["flags"] = flags[0]
+	}
+	_, err = ud.httpPOST(data, "/session", ud.sessionId, "appium/device/press_keycode")
+	return
+}
+
+func (ud *uiaDriver) AlertText() (text string, err error) {
+	// register(getHandler, new GetAlertText("/wd/hub/session/:sessionId/alert/text"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/session", ud.sessionId, "alert/text"); err != nil {
+		return "", err
+	}
+	reply := new(struct{ Value string })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return "", err
+	}
+
+	text = reply.Value
+	return
+}
+
+func (ud *uiaDriver) AlertButtons() (btnLabels []string, err error) {
+	// TODO
+	return btnLabels, errDriverNotImplemented
+}
+
+func (ud *uiaDriver) AlertAccept(label ...string) (err error) {
+	data := map[string]interface{}{
+		"buttonLabel": nil,
+	}
+	if len(label) != 0 {
+		data["buttonLabel"] = label[0]
+	}
+	// register(postHandler, new AcceptAlert("/wd/hub/session/:sessionId/alert/accept"))
+	_, err = ud.httpPOST(data, "/session", ud.sessionId, "alert/accept")
+	return
+}
+
+func (ud *uiaDriver) AlertDismiss(label ...string) (err error) {
+	data := map[string]interface{}{
+		"buttonLabel": nil,
+	}
+	if len(label) != 0 {
+		data["buttonLabel"] = label[0]
+	}
+	// register(postHandler, new DismissAlert("/wd/hub/session/:sessionId/alert/dismiss"))
+	_, err = ud.httpPOST(data, "/session", ud.sessionId, "alert/dismiss")
+	return
+}
+
+func (ud *uiaDriver) AlertSendKeys(text string) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) check() error {
+	if ud.adbDevice.Serial() == "" {
+		return errors.New("adb daemon: the device is not ready")
+	}
+	return nil
+}
+
+func (ud *uiaDriver) AppLaunch(bundleId string, launchOpt ...AppLaunchOption) (err error) {
+	if err = ud.check(); err != nil {
+		return err
+	}
+
+	var sOutput string
+	if sOutput, err = ud.adbDevice.RunShellCommand("monkey -p", bundleId, "-c android.intent.category.LAUNCHER 1"); err != nil {
+		return err
+	}
+	if strings.Contains(sOutput, "monkey aborted") {
+		return fmt.Errorf("app launch: %s", strings.TrimSpace(sOutput))
+	}
+
+	if len(launchOpt) != 0 {
+		var ce error
+		exists := func(ud WebDriver) (bool, error) {
+			for _, opt := range launchOpt {
+				if waitForComplete, ok := opt["androidBySelector"]; ok {
+					for _, e := range waitForComplete.([]BySelector) {
+						_, ce = ud.FindElement(e)
+						if ce == nil {
+							return true, nil
+						}
+					}
+				}
+			}
+			return false, nil
+		}
+		if err = ud.WaitWithTimeoutAndInterval(exists, 45, 1); err != nil {
+			return fmt.Errorf("app launch (waitForComplete): %s: %w", err.Error(), ce)
+		}
+	}
+	return
+}
+
+func (ud *uiaDriver) AppLaunchUnattached(bundleId string) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+// Dispose corresponds to the command:
+//  adb -s $serial forward --remove $localPort
+func (ud *uiaDriver) Dispose() (err error) {
+	if err = ud.check(); err != nil {
+		return err
+	}
+	if ud.localPort == 0 {
+		return nil
+	}
+	return ud.adbDevice.ForwardKill(ud.localPort)
+}
+
+func (ud *uiaDriver) AppTerminate(bundleId string) (successful bool, err error) {
+	if err = ud.check(); err != nil {
+		return false, err
+	}
+
+	_, err = ud.adbDevice.RunShellCommand("am force-stop", bundleId)
+	return err == nil, err
+}
+
+func (ud *uiaDriver) AppActivate(bundleId string) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) AppDeactivate(second float64) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) AppAuthReset(resource ProtectedResource) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) Tap(x, y int, options ...DataOption) error {
+	return ud.TapFloat(float64(x), float64(y), options...)
+}
+
+func (ud *uiaDriver) TapFloat(x, y float64, options ...DataOption) (err error) {
+	// register(postHandler, new Tap("/wd/hub/session/:sessionId/appium/tap"))
+	data := map[string]interface{}{
+		"x": x,
+		"y": y,
+	}
+	// new data options in post data for extra uiautomator configurations
+	d := NewData(data, options...)
+
+	_, err = ud.httpPOST(d.Data, "/session", ud.sessionId, "appium/tap")
+	return
+}
+
+func (ud *uiaDriver) DoubleTap(x, y int) error {
+	return ud.DoubleTapFloat(float64(x), float64(y))
+}
+
+func (ud *uiaDriver) DoubleTapFloat(x, y float64) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) TouchAndHold(x, y int, second ...float64) (err error) {
+	return ud.TouchAndHoldFloat(float64(x), float64(y), second...)
+}
+
+func (ud *uiaDriver) TouchAndHoldFloat(x, y float64, second ...float64) (err error) {
+	if len(second) == 0 {
+		second = []float64{1.0}
+	}
+	// register(postHandler, new TouchLongClick("/wd/hub/session/:sessionId/touch/longclick"))
+	data := map[string]interface{}{
+		"params": map[string]interface{}{
+			"x":        x,
+			"y":        y,
+			"duration": int(second[0] * 1000),
+		},
+	}
+	_, err = ud.httpPOST(data, "/session", ud.sessionId, "touch/longclick")
+	return
+}
+
+func (ud *uiaDriver) _drag(data map[string]interface{}) (err error) {
+	// register(postHandler, new Drag("/wd/hub/session/:sessionId/touch/drag"))
+	_, err = ud.httpPOST(data, "/session", ud.sessionId, "touch/drag")
+	return
+}
+
+// Drag performs a swipe from one coordinate to another coordinate. You can control
+// the smoothness and speed of the swipe by specifying the number of steps.
+// Each step execution is throttled to 5 milliseconds per step, so for a 100
+// steps, the swipe will take around 0.5 seconds to complete.
+func (ud *uiaDriver) Drag(fromX, fromY, toX, toY int, options ...DataOption) error {
+	return ud.DragFloat(float64(fromX), float64(fromY), float64(toX), float64(toY), options...)
+}
+
+func (ud *uiaDriver) DragFloat(fromX, fromY, toX, toY float64, options ...DataOption) (err error) {
+	data := map[string]interface{}{
+		"startX": fromX,
+		"startY": fromY,
+		"endX":   toX,
+		"endY":   toY,
+	}
+
+	// new data options in post data for extra uiautomator configurations
+	d := NewData(data, options...)
+
+	return ud._drag(d.Data)
+}
+
+func (ud *uiaDriver) _swipe(startX, startY, endX, endY interface{}, options ...DataOption) (err error) {
+	// register(postHandler, new Swipe("/wd/hub/session/:sessionId/touch/perform"))
+	data := map[string]interface{}{
+		"startX": startX,
+		"startY": startY,
+		"endX":   endX,
+		"endY":   endY,
+	}
+
+	// new data options in post data for extra uiautomator configurations
+	d := NewData(data, options...)
+
+	_, err = ud.httpPOST(d.Data, "/session", ud.sessionId, "touch/perform")
+	return
+}
+
+// Swipe performs a swipe from one coordinate to another using the number of steps
+// to determine smoothness and speed. Each step execution is throttled to 5ms
+// per step. So for a 100 steps, the swipe will take about 1/2 second to complete.
+//  `steps` is the number of move steps sent to the system
+func (ud *uiaDriver) Swipe(fromX, fromY, toX, toY int, options ...DataOption) error {
+	options = append(options, WithDataSteps(12))
+	return ud.SwipeFloat(float64(fromX), float64(fromY), float64(toX), float64(toY), options...)
+}
+
+func (ud *uiaDriver) SwipeFloat(fromX, fromY, toX, toY float64, options ...DataOption) error {
+	return ud._swipe(fromX, fromY, toX, toY, options...)
+}
+
+func (ud *uiaDriver) ForceTouch(x, y int, pressure float64, second ...float64) error {
+	return ud.ForceTouchFloat(float64(x), float64(y), pressure, second...)
+}
+
+func (ud *uiaDriver) ForceTouchFloat(x, y, pressure float64, second ...float64) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) PerformW3CActions(actions *W3CActions) (err error) {
+	data := map[string]interface{}{
+		"actions": actions,
+	}
+	// register(postHandler, new W3CActions("/wd/hub/session/:sessionId/actions"))
+	_, err = ud.httpPOST(data, "/session", ud.sessionId, "/actions")
+	return
+}
+
+func (ud *uiaDriver) PerformAppiumTouchActions(touchActs *TouchActions) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) SetPasteboard(contentType PasteboardType, content string) (err error) {
+	lbl := content
+
+	const defaultLabelLen = 10
+	if len(lbl) > defaultLabelLen {
+		lbl = lbl[:defaultLabelLen]
+	}
+
+	data := map[string]interface{}{
+		"contentType": contentType,
+		"label":       lbl,
+		"content":     base64.StdEncoding.EncodeToString([]byte(content)),
+	}
+	// register(postHandler, new SetClipboard("/wd/hub/session/:sessionId/appium/device/set_clipboard"))
+	_, err = ud.httpPOST(data, "/session", ud.sessionId, "appium/device/set_clipboard")
+	return
+}
+
+func (ud *uiaDriver) GetPasteboard(contentType PasteboardType) (raw *bytes.Buffer, err error) {
+	if len(contentType) == 0 {
+		contentType = PasteboardTypePlaintext
+	}
+	// register(postHandler, new GetClipboard("/wd/hub/session/:sessionId/appium/device/get_clipboard"))
+	data := map[string]interface{}{
+		"contentType": contentType[0],
+	}
+	var rawResp rawResponse
+	if rawResp, err = ud.httpPOST(data, "/session", ud.sessionId, "appium/device/get_clipboard"); err != nil {
+		return
+	}
+	reply := new(struct{ Value string })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return
+	}
+
+	if data, err := base64.StdEncoding.DecodeString(reply.Value); err != nil {
+		raw.Write([]byte(reply.Value))
+	} else {
+		raw.Write(data)
+	}
+	return
+}
+
+func (ud *uiaDriver) SendKeys(text string, options ...DataOption) (err error) {
+	// register(postHandler, new SendKeysToElement("/wd/hub/session/:sessionId/keys"))
+	// https://github.com/appium/appium-uiautomator2-server/blob/master/app/src/main/java/io/appium/uiautomator2/handler/SendKeysToElement.java#L76-L85
+	data := map[string]interface{}{
+		"text": text,
+	}
+	// new data options in post data for extra uiautomator configurations
+	d := NewData(data, options...)
+
+	_, err = ud.httpPOST(d.Data, "/session", ud.sessionId, "keys")
+	return
+}
+
+func (ud *uiaDriver) Input(text string, options ...DataOption) (err error) {
+	data := map[string]interface{}{
+		"view": text,
+	}
+	// new data options in post data for extra uiautomator configurations
+	d := NewData(data, options...)
+
+	var element WebElement
+	if valuetext, ok := d.Data["textview"]; ok {
+		element, err = ud.FindElement(BySelector{UiAutomator: NewUiSelectorHelper().TextContains(fmt.Sprintf("%v", valuetext)).String()})
+	} else if valueid, ok := d.Data["id"]; ok {
+		element, err = ud.FindElement(BySelector{ResourceIdID: fmt.Sprintf("%v", valueid)})
+	} else if valuedesc, ok := d.Data["description"]; ok {
+		element, err = ud.FindElement(BySelector{UiAutomator: NewUiSelectorHelper().Description(fmt.Sprintf("%v", valuedesc)).String()})
+	} else {
+		element, err = ud.FindElement(BySelector{ClassName: ElementType{EditText: true}})
+	}
+	if err != nil {
+		return err
+	}
+	return element.SendKeys(text, options...)
+}
+
+func (ud *uiaDriver) KeyboardDismiss(keyNames ...string) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) PressButton(devBtn DeviceButton) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) IOHIDEvent(pageID EventPageID, usageID EventUsageID, duration ...float64) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) ExpectNotification(notifyName string, notifyType NotificationType, second ...int) (err error) {
+	// register(postHandler, new OpenNotification("/wd/hub/session/:sessionId/appium/device/open_notifications"))
+	_, err = ud.httpPOST(nil, "/session", ud.sessionId, "appium/device/open_notifications")
+	return
+}
+
+func (ud *uiaDriver) SiriActivate(text string) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) SiriOpenUrl(url string) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) Orientation() (orientation Orientation, err error) {
+	// register(getHandler, new GetOrientation("/wd/hub/session/:sessionId/orientation"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/session", ud.sessionId, "orientation"); err != nil {
+		return "", err
+	}
+	reply := new(struct{ Value Orientation })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return "", err
+	}
+
+	orientation = reply.Value
+	return
+}
+
+func (ud *uiaDriver) SetOrientation(orientation Orientation) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) Rotation() (rotation Rotation, err error) {
+	// register(getHandler, new GetRotation("/wd/hub/session/:sessionId/rotation"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/session", ud.sessionId, "rotation"); err != nil {
+		return Rotation{}, err
+	}
+	reply := new(struct{ Value Rotation })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return Rotation{}, err
+	}
+
+	rotation = reply.Value
+	return
+}
+
+func (ud *uiaDriver) SetRotation(rotation Rotation) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) MatchTouchID(isMatch bool) (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) _findElements(method, selector string, elementID ...string) (elements []WebElement, err error) {
+	// register(postHandler, new FindElements("/wd/hub/session/:sessionId/elements"))
+	data := map[string]interface{}{
+		"strategy": method,
+		"selector": selector,
+	}
+	if len(elementID) != 0 {
+		data["context"] = elementID[0]
+	}
+	var rawResp rawResponse
+	if rawResp, err = ud.httpPOST(data, "/session", ud.sessionId, "/elements"); err != nil {
+		return nil, err
+	}
+	reply := new(struct{ Value []map[string]string })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+	if len(reply.Value) == 0 {
+		return nil, fmt.Errorf("no such element: unable to find an element using '%s', value '%s'", method, selector)
+	}
+	elements = make([]WebElement, len(reply.Value))
+	for i, elem := range reply.Value {
+		var id string
+		if id = elementIDFromValue(elem); id == "" {
+			return nil, fmt.Errorf("invalid element returned: %+v", reply)
+		}
+		uie := WebElement(uiaElement{parent: ud, id: id})
+		elements[i] = uie
+	}
+	return
+}
+
+func (ud *uiaDriver) _findElement(method, selector string, elementID ...string) (elem *uiaElement, err error) {
+	// register(postHandler, new FindElement("/wd/hub/session/:sessionId/element"))
+	data := map[string]interface{}{
+		"strategy": method,
+		"selector": selector,
+	}
+	if len(elementID) != 0 {
+		data["context"] = elementID[0]
+	}
+	var rawResp rawResponse
+	if rawResp, err = ud.httpPOST(data, "/session", ud.sessionId, "/element"); err != nil {
+		return nil, err
+	}
+	reply := new(struct{ Value map[string]string })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+	if len(reply.Value) == 0 {
+		return nil, fmt.Errorf("no such element: unable to find an element using '%s', value '%s'", method, selector)
+	}
+	var id string
+	if id = elementIDFromValue(reply.Value); id == "" {
+		return nil, fmt.Errorf("invalid element returned: %+v", reply)
+	}
+	elem = &uiaElement{parent: ud, id: id}
+	return
+}
+
+func (ud *uiaDriver) ActiveElement() (element WebElement, err error) {
+	// TODO
+	return element, errDriverNotImplemented
+}
+
+func (ud *uiaDriver) FindElement(by BySelector) (element WebElement, err error) {
+	return ud._findElement(by.getUsingAndValue())
+}
+
+func (ud *uiaDriver) FindElements(by BySelector) (elements []WebElement, err error) {
+	// [[FBRoute POST:@"/elements"] respondWithTarget:self action:@selector(handleFindElements:)]
+	using, value := by.getUsingAndValue()
+	data := map[string]interface{}{
+		"using": using,
+		"value": value,
+	}
+	var rawResp rawResponse
+	if rawResp, err = ud.httpPOST(data, "/session", ud.sessionId, "/elements"); err != nil {
+		return nil, err
+	}
+	var elementIDs []string
+	if elementIDs, err = rawResp.valueConvertToElementIDs(); err != nil {
+		if errors.Is(err, errNoSuchElement) {
+			return nil, fmt.Errorf("%w: unable to find an element using '%s', value '%s'", err, using, value)
+		}
+		return nil, err
+	}
+	elements = make([]WebElement, len(elementIDs))
+	for i := range elementIDs {
+		elements[i] = WebElement(uiaElement{parent: ud, id: elementIDs[i]})
+	}
+	return
+}
+
+func (ud *uiaDriver) Screenshot() (raw *bytes.Buffer, err error) {
+	// register(getHandler, new CaptureScreenshot("/wd/hub/session/:sessionId/screenshot"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/session", ud.sessionId, "screenshot"); err != nil {
+		return nil, errors.Wrap(code.AndroidScreenShotError,
+			fmt.Sprintf("get UIA screenshot data failed: %v", err))
+	}
+	reply := new(struct{ Value string })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+
+	var decodeStr []byte
+	if decodeStr, err = base64.StdEncoding.DecodeString(reply.Value); err != nil {
+		return nil, errors.Wrap(code.AndroidScreenShotError,
+			fmt.Sprintf("decode UIA screenshot data failed: %v", err))
+	}
+
+	raw = bytes.NewBuffer(decodeStr)
+	return
+}
+
+func (ud *uiaDriver) Source(srcOpt ...SourceOption) (source string, err error) {
+	// register(getHandler, new Source("/wd/hub/session/:sessionId/source"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/session", ud.sessionId, "source"); err != nil {
+		return "", err
+	}
+	reply := new(struct{ Value string })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return "", err
+	}
+
+	source = reply.Value
+	return
+}
+
+func (ud *uiaDriver) AccessibleSource() (source string, err error) {
+	// TODO
+	return source, errDriverNotImplemented
+}
+
+func (ud *uiaDriver) HealthCheck() (err error) {
+	// TODO
+	return errDriverNotImplemented
+}
+
+func (ud *uiaDriver) GetAppiumSettings() (settings map[string]interface{}, err error) {
+	// register(getHandler, new GetSettings("/wd/hub/session/:sessionId/appium/settings"))
+	var rawResp rawResponse
+	if rawResp, err = ud.httpGET("/session", ud.sessionId, "appium/settings"); err != nil {
+		return nil, err
+	}
+	reply := new(struct{ Value map[string]interface{} })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+
+	settings = reply.Value
+	return
+}
+
+func (ud *uiaDriver) SetAppiumSettings(settings map[string]interface{}) (ret map[string]interface{}, err error) {
+	data := map[string]interface{}{
+		"settings": settings,
+	}
+	// register(postHandler, new UpdateSettings("/wd/hub/session/:sessionId/appium/settings"))
+	_, err = ud.httpPOST(data, "/session", ud.sessionId, "appium/settings")
+	return
+}
+
+func (ud *uiaDriver) IsHealthy() (healthy bool, err error) {
+	// TODO
+	return healthy, errDriverNotImplemented
+}
+
+func (ud *uiaDriver) WaitWithTimeoutAndInterval(condition Condition, timeout, interval time.Duration) error {
+	startTime := time.Now()
+	for {
+		done, err := condition(ud)
+		if err != nil {
+			return err
+		}
+		if done {
+			return nil
+		}
+
+		if elapsed := time.Since(startTime); elapsed > timeout {
+			return fmt.Errorf("timeout after %v", elapsed)
+		}
+		time.Sleep(interval)
+	}
+}
+
+func (ud *uiaDriver) WaitWithTimeout(condition Condition, timeout time.Duration) error {
+	return ud.WaitWithTimeoutAndInterval(condition, timeout, DefaultWaitInterval)
+}
+
+func (ud *uiaDriver) Wait(condition Condition) error {
+	return ud.WaitWithTimeoutAndInterval(condition, DefaultWaitTimeout, DefaultWaitInterval)
+}
+
+func (ud *uiaDriver) StartCaptureLog(identifier ...string) (err error) {
+	log.Info().Msg("start adb log recording")
+	err = ud.logcat.CatchLogcat()
+	if err != nil {
+		err = errors.Wrap(code.IOSCaptureLogError,
+			fmt.Sprintf("start adb log recording failed: %v", err))
+		return err
+	}
+	return nil
+}
+
+func (ud *uiaDriver) StopCaptureLog() (result interface{}, err error) {
+	log.Info().Msg("stop adb log recording")
+	err = ud.logcat.Stop()
+	if err != nil {
+		log.Error().Err(err).Msg("failed to get adb log recording")
+		err = errors.Wrap(code.IOSCaptureLogError,
+			fmt.Sprintf("get adb log recording failed: %v", err))
+		return "", err
+	}
+	content := ud.logcat.logBuffer.String()
+	return ConvertPoints(content), nil
+}
diff --git a/hrp/pkg/uixt/android_elment.go b/hrp/pkg/uixt/android_elment.go
new file mode 100644
index 000000000..9d45cd397
--- /dev/null
+++ b/hrp/pkg/uixt/android_elment.go
@@ -0,0 +1,306 @@
+package uixt
+
+import (
+	"bytes"
+	"encoding/base64"
+	"encoding/json"
+
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+)
+
+var errElementNotImplemented = errors.New("element method not implemented")
+
+type uiaElement struct {
+	parent *uiaDriver
+	id     string
+}
+
+func (ue uiaElement) Click() (err error) {
+	// register(postHandler, new Click("/wd/hub/session/:sessionId/element/:id/click"))
+	_, err = ue.parent.httpPOST(nil, "/session", ue.parent.sessionId, "/element", ue.id, "/click")
+	return
+}
+
+func (ue uiaElement) SendKeys(text string, options ...DataOption) (err error) {
+	// register(postHandler, new SendKeysToElement("/wd/hub/session/:sessionId/element/:id/value"))
+	// https://github.com/appium/appium-uiutomator2-server/blob/master/app/src/main/java/io/appium/uiutomator2/handler/SendKeysToElement.java#L76-L85
+	data := map[string]interface{}{
+		"text": text,
+	}
+
+	// new data options in post data for extra uiautomator configurations
+	d := NewData(data, options...)
+
+	_, err = ue.parent.httpPOST(d.Data, "/session", ue.parent.sessionId, "/element", ue.id, "/value")
+	return
+}
+
+func (ue uiaElement) Clear() (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) Tap(x, y int) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) TapFloat(x, y float64) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) DoubleTap() (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) TouchAndHold(second ...float64) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) TwoFingerTap() (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) TapWithNumberOfTaps(numberOfTaps, numberOfTouches int) (err error) {
+	// Todo: implement
+	log.Fatal().Msg("not support")
+	return
+}
+
+func (ue uiaElement) ForceTouch(pressure float64, second ...float64) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) ForceTouchFloat(x, y, pressure float64, second ...float64) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) Drag(fromX, fromY, toX, toY int, steps ...float64) (err error) {
+	return ue.DragFloat(float64(fromX), float64(fromY), float64(toX), float64(toY), steps...)
+}
+
+func (ue uiaElement) DragFloat(fromX, fromY, toX, toY float64, steps ...float64) (err error) {
+	if len(steps) == 0 {
+		steps = []float64{12 * 10}
+	} else {
+		steps[0] = 12 * 10
+	}
+	data := map[string]interface{}{
+		"elementId": ue.id,
+		"endX":      toX,
+		"endY":      toY,
+		"steps":     steps[0],
+	}
+	return ue.parent._drag(data)
+}
+
+func (ue uiaElement) Swipe(fromX, fromY, toX, toY int) error {
+	return ue.SwipeFloat(float64(fromX), float64(fromY), float64(toX), float64(toY))
+}
+
+func (ue uiaElement) SwipeFloat(fromX, fromY, toX, toY float64) error {
+	options := []DataOption{
+		WithDataSteps(12),
+		WithCustomOption("elementId", ue.id),
+	}
+	return ue.parent._swipe(fromX, fromY, toX, toY, options...)
+}
+
+func (ue uiaElement) SwipeDirection(direction Direction, velocity ...float64) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) Pinch(scale, velocity float64) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) PinchToZoomOutByW3CAction(scale ...float64) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) Rotate(rotation float64, velocity ...float64) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) PickerWheelSelect(order PickerWheelOrder, offset ...int) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) scroll(data interface{}) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) ScrollElementByName(name string) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) ScrollElementByPredicate(predicate string) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) ScrollToVisible() (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) ScrollDirection(direction Direction, distance ...float64) (err error) {
+	// TODO
+	return errElementNotImplemented
+}
+
+func (ue uiaElement) FindElement(by BySelector) (element WebElement, err error) {
+	method, selector := by.getMethodAndSelector()
+	return ue.parent._findElement(method, selector, ue.id)
+}
+
+func (ue uiaElement) FindElements(by BySelector) (elements []WebElement, err error) {
+	method, selector := by.getMethodAndSelector()
+	return ue.parent._findElements(method, selector, ue.id)
+}
+
+func (ue uiaElement) FindVisibleCells() (elements []WebElement, err error) {
+	// TODO
+	return elements, errElementNotImplemented
+}
+
+func (ue uiaElement) Rect() (rect Rect, err error) {
+	// register(getHandler, new GetRect("/wd/hub/session/:sessionId/element/:id/rect"))
+	var rawResp rawResponse
+	if rawResp, err = ue.parent.httpGET("/session", ue.parent.sessionId, "/element", ue.id, "/rect"); err != nil {
+		return Rect{}, err
+	}
+	reply := new(struct{ Value Rect })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return Rect{}, err
+	}
+	rect = reply.Value
+	return
+}
+
+func (ue uiaElement) Location() (point Point, err error) {
+	// register(getHandler, new Location("/wd/hub/session/:sessionId/element/:id/location"))
+	var rawResp rawResponse
+	if rawResp, err = ue.parent.httpGET("/session", ue.parent.sessionId, "/element", ue.id, "/location"); err != nil {
+		return Point{-1, -1}, err
+	}
+	reply := new(struct{ Value Point })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return Point{-1, -1}, err
+	}
+	point = reply.Value
+	return
+}
+
+func (ue uiaElement) Size() (size Size, err error) {
+	// register(getHandler, new GetSize("/wd/hub/session/:sessionId/element/:id/size"))
+	var rawResp rawResponse
+	if rawResp, err = ue.parent.httpGET("/session", ue.parent.sessionId, "/element", ue.id, "/size"); err != nil {
+		return Size{-1, -1}, err
+	}
+	reply := new(struct{ Value Size })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return Size{-1, -1}, err
+	}
+	size = reply.Value
+	return
+}
+
+func (ue uiaElement) Text() (text string, err error) {
+	// register(getHandler, new GetText("/wd/hub/session/:sessionId/element/:id/text"))
+	var rawResp rawResponse
+	if rawResp, err = ue.parent.httpGET("/session", ue.parent.sessionId, "/element", ue.id, "/text"); err != nil {
+		return "", err
+	}
+	reply := new(struct{ Value string })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return "", err
+	}
+	text = reply.Value
+	return
+}
+
+func (ue uiaElement) Type() (elemType string, err error) {
+	// TODO
+	return elemType, errElementNotImplemented
+}
+
+func (ue uiaElement) IsEnabled() (enabled bool, err error) {
+	// TODO
+	return enabled, errElementNotImplemented
+}
+
+func (ue uiaElement) IsDisplayed() (displayed bool, err error) {
+	// TODO
+	return displayed, errElementNotImplemented
+}
+
+func (ue uiaElement) IsSelected() (selected bool, err error) {
+	// TODO
+	return selected, errElementNotImplemented
+}
+
+func (ue uiaElement) IsAccessible() (accessible bool, err error) {
+	// TODO
+	return accessible, errElementNotImplemented
+}
+
+func (ue uiaElement) IsAccessibilityContainer() (isAccessibilityContainer bool, err error) {
+	// TODO
+	return isAccessibilityContainer, errElementNotImplemented
+}
+
+func (ue uiaElement) GetAttribute(attr ElementAttribute) (value string, err error) {
+	// register(getHandler, new GetElementAttribute("/wd/hub/session/:sessionId/element/:id/attribute/:name"))
+	var rawResp rawResponse
+	if rawResp, err = ue.parent.httpGET("/session", ue.parent.sessionId, "/element", ue.id, "/attribute", attr.getAttributeName()); err != nil {
+		return "", err
+	}
+	reply := new(struct{ Value string })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return "", err
+	}
+	value = reply.Value
+	return
+}
+
+func (ue uiaElement) UID() (uid string) {
+	return ue.id
+}
+
+func (ue uiaElement) Screenshot() (raw *bytes.Buffer, err error) {
+	// W3C endpoint
+	// register(getHandler, new GetElementScreenshot("/wd/hub/session/:sessionId/element/:id/screenshot"))
+	// JSONWP endpoint
+	// register(getHandler, new GetElementScreenshot("/wd/hub/session/:sessionId/screenshot/:id"))
+	var rawResp rawResponse
+	if rawResp, err = ue.parent.httpGET("/session", ue.parent.sessionId, "/element", ue.id, "/screenshot"); err != nil {
+		return nil, err
+	}
+	reply := new(struct{ Value string })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+
+	var decodeStr []byte
+	if decodeStr, err = base64.StdEncoding.DecodeString(reply.Value); err != nil {
+		return nil, err
+	}
+
+	raw = bytes.NewBuffer(decodeStr)
+	return
+}
diff --git a/hrp/pkg/uixt/android_key.go b/hrp/pkg/uixt/android_key.go
new file mode 100644
index 000000000..07f0d5b7d
--- /dev/null
+++ b/hrp/pkg/uixt/android_key.go
@@ -0,0 +1,879 @@
+package uixt
+
+type KeyMeta int
+
+const (
+	KMEmpty     KeyMeta = 0     // As a `null`
+	KMCapLocked KeyMeta = 0x100 // SHIFT key locked in CAPS mode.
+	KMAltLocked KeyMeta = 0x200 // ALT key locked.
+	KMSymLocked KeyMeta = 0x400 // SYM key locked.
+	KMSelecting KeyMeta = 0x800 // Text is in selection mode.
+	// KMAltOn        KeyMeta = 0x02     // This mask is used to check whether one of the ALT meta keys is pressed.
+	// KMAltLeftOn    KeyMeta = 0x10     // This mask is used to check whether the left ALT meta key is pressed.
+	// KMAltRightOn   KeyMeta = 0x20     // This mask is used to check whether the right the ALT meta key is pressed.
+	// KMShiftOn      KeyMeta = 0x1      // This mask is used to check whether one of the SHIFT meta keys is pressed.
+	// KMShiftLeftOn  KeyMeta = 0x40     // This mask is used to check whether the left SHIFT meta key is pressed.
+	// KMShiftRightOn KeyMeta = 0x80     // This mask is used to check whether the right SHIFT meta key is pressed.
+	// KMSymOn        KeyMeta = 0x4      // This mask is used to check whether the SYM meta key is pressed.
+	// KMFunctionOn   KeyMeta = 0x8      // This mask is used to check whether the FUNCTION meta key is pressed.
+	// KMCtrlOn       KeyMeta = 0x1000   // This mask is used to check whether one of the CTRL meta keys is pressed.
+	// KMCtrlLeftOn   KeyMeta = 0x2000   // This mask is used to check whether the left CTRL meta key is pressed.
+	// KMCtrlRightOn  KeyMeta = 0x4000   // This mask is used to check whether the right CTRL meta key is pressed.
+	// KMMetaOn       KeyMeta = 0x10000  // This mask is used to check whether one of the META meta keys is pressed.
+	// KMMetaLeftOn   KeyMeta = 0x20000  // This mask is used to check whether the left META meta key is pressed.
+	// KMMetaRightOn  KeyMeta = 0x40000  // This mask is used to check whether the right META meta key is pressed.
+	// KMCapsLockOn   KeyMeta = 0x100000 // This mask is used to check whether the CAPS LOCK meta key is on.
+	// KMNumLockOn    KeyMeta = 0x200000 // This mask is used to check whether the NUM LOCK meta key is on.
+	// KMScrollLockOn KeyMeta = 0x400000 // This mask is used to check whether the SCROLL LOCK meta key is on.
+	// KMShiftMask            = KMShiftOn | KMShiftLeftOn | KMShiftRightOn
+	// KMAltMask              = KMAltOn | KMAltLeftOn | KMAltRightOn
+	// KMCtrlMask             = KMCtrlOn | KMCtrlLeftOn | KMCtrlRightOn
+	// KMMetaMask             = KMMetaOn | KMMetaLeftOn | KMMetaRightOn
+)
+
+type KeyFlag int
+
+const (
+	// KFWokeHere This mask is set if the device woke because of this key event.
+	// Deprecated
+	KFWokeHere KeyFlag = 0x1
+
+	// KFSoftKeyboard This mask is set if the key event was generated by a software keyboard.
+	KFSoftKeyboard KeyFlag = 0x2
+
+	// KFKeepTouchMode This mask is set if we don't want the key event to cause us to leave touch mode.
+	KFKeepTouchMode KeyFlag = 0x4
+
+	// KFFromSystem This mask is set if an event was known to come from a trusted part
+	// of the system.  That is, the event is known to come from the user,
+	// and could not have been spoofed by a third party component.
+	KFFromSystem KeyFlag = 0x8
+
+	// KFEditorAction This mask is used for compatibility, to identify enter keys that are
+	// coming from an IME whose enter key has been auto-labelled "next" or
+	// "done".  This allows TextView to dispatch these as normal enter keys
+	// for old applications, but still do the appropriate action when receiving them.
+	KFEditorAction KeyFlag = 0x10
+
+	// KFCanceled When associated with up key events, this indicates that the key press
+	// has been canceled.  Typically this is used with virtual touch screen
+	// keys, where the user can slide from the virtual key area on to the
+	// display: in that case, the application will receive a canceled up
+	// event and should not perform the action normally associated with the
+	// key.  Note that for this to work, the application can not perform an
+	// action for a key until it receives an up or the long press timeout has expired.
+	KFCanceled KeyFlag = 0x20
+
+	// KFVirtualHardKey This key event was generated by a virtual (on-screen) hard key area.
+	// Typically this is an area of the touchscreen, outside of the regular
+	// display, dedicated to "hardware" buttons.
+	KFVirtualHardKey KeyFlag = 0x40
+
+	// KFLongPress This flag is set for the first key repeat that occurs after the long press timeout.
+	KFLongPress KeyFlag = 0x80
+
+	// KFCanceledLongPress Set when a key event has `KFCanceled` set because a long
+	// press action was executed while it was down.
+	KFCanceledLongPress KeyFlag = 0x100
+
+	// KFTracking Set for `ACTION_UP` when this event's key code is still being
+	// tracked from its initial down.  That is, somebody requested that tracking
+	// started on the key down and a long press has not caused
+	// the tracking to be canceled.
+	KFTracking KeyFlag = 0x200
+
+	// KFFallback Set when a key event has been synthesized to implement default behavior
+	// for an event that the application did not handle.
+	// Fallback key events are generated by unhandled trackball motions
+	// (to emulate a directional keypad) and by certain unhandled key presses
+	// that are declared in the key map (such as special function numeric keypad
+	// keys when numlock is off).
+	KFFallback KeyFlag = 0x400
+
+	// KFPredispatch Signifies that the key is being predispatched.
+	// KFPredispatch KeyFlag = 0x20000000
+
+	// KFStartTracking Private control to determine when an app is tracking a key sequence.
+	// KFStartTracking KeyFlag = 0x40000000
+
+	// KFTainted Private flag that indicates when the system has detected that this key event
+	// may be inconsistent with respect to the sequence of previously delivered key events,
+	// such as when a key up event is sent but the key was not down.
+	// KFTainted KeyFlag = 0x80000000
+)
+
+type KeyCode int
+
+const (
+	_ KeyCode = 0 // Unknown key code.
+
+	// KCSoftLeft Soft Left key
+	// Usually situated below the display on phones and used as a multi-function
+	// feature key for selecting a software defined function shown on the bottom left
+	// of the display.
+	KCSoftLeft KeyCode = 1
+
+	// KCSoftRight Soft Right key.
+	// Usually situated below the display on phones and used as a multi-function
+	// feature key for selecting a software defined function shown on the bottom right
+	// of the display.
+	KCSoftRight KeyCode = 2
+
+	// KCHome Home key.
+	// This key is handled by the framework and is never delivered to applications.
+	KCHome KeyCode = 3
+
+	KCBack    KeyCode = 4  // Back key
+	KCCall    KeyCode = 5  // Call key
+	KCEndCall KeyCode = 6  // End Call key
+	KC0       KeyCode = 7  // '0' key
+	KC1       KeyCode = 8  // '1' key
+	KC2       KeyCode = 9  // '2' key
+	KC3       KeyCode = 10 // '3' key
+	KC4       KeyCode = 11 // '4' key
+	KC5       KeyCode = 12 // '5' key
+	KC6       KeyCode = 13 // '6' key
+	KC7       KeyCode = 14 // '7' key
+	KC8       KeyCode = 15 // '8' key
+	KC9       KeyCode = 16 // '9' key
+	KCStar    KeyCode = 17 // '*' key
+	KCPound   KeyCode = 18 // '#' key
+
+	// KCDPadUp KeycodeDPadUp Directional Pad Up key.
+	// May also be synthesized from trackball motions.
+	KCDPadUp KeyCode = 19
+
+	// KCDPadDown Directional Pad Down key.
+	// May also be synthesized from trackball motions.
+	KCDPadDown KeyCode = 20
+
+	// KCDPadLeft Directional Pad Left key.
+	// May also be synthesized from trackball motions.
+	KCDPadLeft KeyCode = 21
+
+	// KCDPadRight Directional Pad Right key.
+	// May also be synthesized from trackball motions.
+	KCDPadRight KeyCode = 22
+
+	// KCDPadCenter Directional Pad Center key.
+	// May also be synthesized from trackball motions.
+	KCDPadCenter KeyCode = 23
+
+	// KCVolumeUp Volume Up key.
+	// Adjusts the speaker volume up.
+	KCVolumeUp KeyCode = 24
+
+	// KCVolumeDown Volume Down key.
+	// Adjusts the speaker volume down.
+	KCVolumeDown KeyCode = 25
+
+	// KCPower Power key.
+	KCPower KeyCode = 26
+
+	// KCCamera Camera key.
+	// Used to launch a camera application or take pictures.
+	KCCamera KeyCode = 27
+
+	KCClear      KeyCode = 28 // Clear key
+	KCa          KeyCode = 29 // 'a' key
+	KCb          KeyCode = 30 // 'b' key
+	KCc          KeyCode = 31 // 'c' key
+	KCd          KeyCode = 32 // 'd' key
+	KCe          KeyCode = 33 // 'e' key
+	KCf          KeyCode = 34 // 'f' key
+	KCg          KeyCode = 35 // 'g' key
+	KCh          KeyCode = 36 // 'h' key
+	KCi          KeyCode = 37 // 'i' key
+	KCj          KeyCode = 38 // 'j' key
+	KCk          KeyCode = 39 // 'k' key
+	KCl          KeyCode = 40 // 'l' key
+	KCm          KeyCode = 41 // 'm' key
+	KCn          KeyCode = 42 // 'n' key
+	KCo          KeyCode = 43 // 'o' key
+	KCp          KeyCode = 44 // 'p' key
+	KCq          KeyCode = 45 // 'q' key
+	KCr          KeyCode = 46 // 'r' key
+	KCs          KeyCode = 47 // 's' key
+	KCt          KeyCode = 48 // 't' key
+	KCu          KeyCode = 49 // 'u' key
+	KCv          KeyCode = 50 // 'v' key
+	KCw          KeyCode = 51 // 'w' key
+	KCx          KeyCode = 52 // 'x' key
+	KCy          KeyCode = 53 // 'y' key
+	KCz          KeyCode = 54 // 'z' key
+	KCComma      KeyCode = 55 // ',' key
+	KCPeriod     KeyCode = 56 // '.' key
+	KCAltLeft    KeyCode = 57 // Left Alt modifier key
+	KCAltRight   KeyCode = 58 // Right Alt modifier key
+	KCShiftLeft  KeyCode = 59 // Left Shift modifier key
+	KCShiftRight KeyCode = 60 // Right Shift modifier key
+	KCTab        KeyCode = 61 // Tab key
+	KCSpace      KeyCode = 62 // Space key
+
+	// KCSym Symbol modifier key.
+	// Used to enter alternate symbols.
+	KCSym KeyCode = 63
+
+	// KCExplorer Explorer special function key.
+	// Used to launch a browser application.
+	KCExplorer KeyCode = 64
+
+	// KCEnvelope Envelope special function key.
+	// Used to launch a mail application.
+	KCEnvelope KeyCode = 65
+
+	// KCEnter Enter key.
+	KCEnter KeyCode = 66
+
+	// KCDel Backspace key.
+	// Deletes characters before the insertion point, unlike `KCForwardDel`.
+	KCDel KeyCode = 67
+
+	KCGrave        KeyCode = 68 // '`' (backtick) key
+	KCMinus        KeyCode = 69 // '-'
+	KCEquals       KeyCode = 70 // '=' key
+	KCLeftBracket  KeyCode = 71 // '[' key
+	KCRightBracket KeyCode = 72 // ']' key
+	KCBackslash    KeyCode = 73 // '\' key
+	KCSemicolon    KeyCode = 74 // '' key
+	KCApostrophe   KeyCode = 75 // ''' (apostrophe) key
+	KCSlash        KeyCode = 76 // '/' key
+	KCAt           KeyCode = 77 // '@' key
+
+	// KCNum Number modifier key.
+	// Used to enter numeric symbols.
+	// This key is not Num Lock; it is more like `KCAltLeft` and is
+	// interpreted as an ALT key by {@link android.text.method.MetaKeyKeyListener}.
+	KCNum KeyCode = 78
+
+	// KCHeadsetHook Headset Hook key.
+	// Used to hang up calls and stop media.
+	KCHeadsetHook KeyCode = 79
+
+	// KCFocus Camera Focus key.
+	// Used to focus the camera.
+	// *Camera* focus
+	KCFocus KeyCode = 80
+
+	KCPlus             KeyCode = 81 // '+' key.
+	KCMenu             KeyCode = 82 // Menu key.
+	KCNotification     KeyCode = 83 // Notification key.
+	KCSearch           KeyCode = 84 // Search key.
+	KCMediaPlayPause   KeyCode = 85 // Play/Pause media key.
+	KCMediaStop        KeyCode = 86 // Stop media key.
+	KCMediaNext        KeyCode = 87 // Play Next media key.
+	KCMediaPrevious    KeyCode = 88 // Play Previous media key.
+	KCMediaRewind      KeyCode = 89 // Rewind media key.
+	KCMediaFastForward KeyCode = 90 // Fast Forward media key.
+
+	// KCMute Mute key.
+	// Mutes the microphone, unlike `KCVolumeMute`
+	KCMute KeyCode = 91
+
+	// KCPageUp Page Up key.
+	KCPageUp KeyCode = 92
+
+	// KCPageDown Page Down key.
+	KCPageDown KeyCode = 93
+
+	// KCPictSymbols Picture Symbols modifier key.
+	// Used to switch symbol sets (Emoji, Kao-moji).
+	// switch symbol-sets (Emoji,Kao-moji)
+	KCPictSymbols KeyCode = 94
+
+	// KCSwitchCharset Switch Charset modifier key.
+	// Used to switch character sets (Kanji, Katakana).
+	// switch char-sets (Kanji,Katakana)
+	KCSwitchCharset KeyCode = 95
+
+	// KCButtonA A Button key.
+	// On a game controller, the A button should be either the button labeled A
+	// or the first button on the bottom row of controller buttons.
+	KCButtonA KeyCode = 96
+
+	// KCButtonB B Button key.
+	// On a game controller, the B button should be either the button labeled B
+	// or the second button on the bottom row of controller buttons.
+	KCButtonB KeyCode = 97
+
+	// KCButtonC C Button key.
+	// On a game controller, the C button should be either the button labeled C
+	// or the third button on the bottom row of controller buttons.
+	KCButtonC KeyCode = 98
+
+	// KCButtonX X Button key.
+	// On a game controller, the X button should be either the button labeled X
+	// or the first button on the upper row of controller buttons.
+	KCButtonX KeyCode = 99
+
+	// KCButtonY Y Button key.
+	// On a game controller, the Y button should be either the button labeled Y
+	// or the second button on the upper row of controller buttons.
+	KCButtonY KeyCode = 100
+
+	// KCButtonZ Z Button key.
+	// On a game controller, the Z button should be either the button labeled Z
+	// or the third button on the upper row of controller buttons.
+	KCButtonZ KeyCode = 101
+
+	// KCButtonL1 L1 Button key.
+	// On a game controller, the L1 button should be either the button labeled L1 (or L)
+	// or the top left trigger button.
+	KCButtonL1 KeyCode = 102
+
+	// KCButtonR1 R1 Button key.
+	// On a game controller, the R1 button should be either the button labeled R1 (or R)
+	// or the top right trigger button.
+	KCButtonR1 KeyCode = 103
+
+	// KCButtonL2 L2 Button key.
+	// On a game controller, the L2 button should be either the button labeled L2
+	// or the bottom left trigger button.
+	KCButtonL2 KeyCode = 104
+
+	// KCButtonR2 R2 Button key.
+	// On a game controller, the R2 button should be either the button labeled R2
+	// or the bottom right trigger button.
+	KCButtonR2 KeyCode = 105
+
+	// KCButtonTHUMBL Left Thumb Button key.
+	// On a game controller, the left thumb button indicates that the left (or only)
+	// joystick is pressed.
+	KCButtonTHUMBL KeyCode = 106
+
+	// KCButtonTHUMBR Right Thumb Button key.
+	// On a game controller, the right thumb button indicates that the right
+	// joystick is pressed.
+	KCButtonTHUMBR KeyCode = 107
+
+	// KCButtonStart Start Button key.
+	// On a game controller, the button labeled Start.
+	KCButtonStart KeyCode = 108
+
+	// KCButtonSelect Select Button key.
+	// On a game controller, the button labeled Select.
+	KCButtonSelect KeyCode = 109
+
+	// KCButtonMode Mode Button key.
+	// On a game controller, the button labeled Mode.
+	KCButtonMode KeyCode = 110
+
+	// KCEscape Escape key.
+	KCEscape KeyCode = 111
+
+	// KCForwardDel Forward Delete key.
+	// Deletes characters ahead of the insertion point, unlike `KCDel`.
+	KCForwardDel KeyCode = 112
+
+	KCCtrlLeft   KeyCode = 113 // Left Control modifier key
+	KCCtrlRight  KeyCode = 114 // Right Control modifier key
+	KCCapsLock   KeyCode = 115 // Caps Lock key
+	KCScrollLock KeyCode = 116 // Scroll Lock key
+	KCMetaLeft   KeyCode = 117 // Left Meta modifier key
+	KCMetaRight  KeyCode = 118 // Right Meta modifier key
+	KCFunction   KeyCode = 119 // Function modifier key
+	KCSysRq      KeyCode = 120 // System Request / Print Screen key
+	KCBreak      KeyCode = 121 // Break / Pause key
+
+	// KCMoveHome Home Movement key.
+	// Used for scrolling or moving the cursor around to the start of a line
+	// or to the top of a list.
+	KCMoveHome KeyCode = 122
+
+	// KCMoveEnd End Movement key.
+	// Used for scrolling or moving the cursor around to the end of a line
+	// or to the bottom of a list.
+	KCMoveEnd KeyCode = 123
+
+	// KCInsert Insert key.
+	// Toggles insert / overwrite edit mode.
+	KCInsert KeyCode = 124
+
+	// KCForward Forward key.
+	// Navigates forward in the history stack.  Complement of `KCBack`.
+	KCForward KeyCode = 125
+
+	// KCMediaPlay Play media key.
+	KCMediaPlay KeyCode = 126
+
+	// KCMediaPause Pause media key.
+	KCMediaPause KeyCode = 127
+
+	// KCMediaClose Close media key.
+	// May be used to close a CD tray, for example.
+	KCMediaClose KeyCode = 128
+
+	// KCMediaEject Eject media key.
+	// May be used to eject a CD tray, for example.
+	KCMediaEject KeyCode = 129
+
+	// KCMediaRecord Record media key.
+	KCMediaRecord KeyCode = 130
+
+	KCF1  KeyCode = 131 // F1 key.
+	KCF2  KeyCode = 132 // F2 key.
+	KCF3  KeyCode = 133 // F3 key.
+	KCF4  KeyCode = 134 // F4 key.
+	KCF5  KeyCode = 135 // F5 key.
+	KCF6  KeyCode = 136 // F6 key.
+	KCF7  KeyCode = 137 // F7 key.
+	KCF8  KeyCode = 138 // F8 key.
+	KCF9  KeyCode = 139 // F9 key.
+	KCF10 KeyCode = 140 // F10 key.
+	KCF11 KeyCode = 141 // F11 key.
+	KCF12 KeyCode = 142 // F12 key.
+
+	// KCNumLock Num Lock key.
+	// This is the Num Lock key; it is different from `KCNum`.
+	// This key alters the behavior of other keys on the numeric keypad.
+	KCNumLock KeyCode = 143
+
+	KCNumpad0          KeyCode = 144 // Numeric keypad '0' key
+	KCNumpad1          KeyCode = 145 // Numeric keypad '1' key
+	KCNumpad2          KeyCode = 146 // Numeric keypad '2' key
+	KCNumpad3          KeyCode = 147 // Numeric keypad '3' key
+	KCNumpad4          KeyCode = 148 // Numeric keypad '4' key
+	KCNumpad5          KeyCode = 149 // Numeric keypad '5' key
+	KCNumpad6          KeyCode = 150 // Numeric keypad '6' key
+	KCNumpad7          KeyCode = 151 // Numeric keypad '7' key
+	KCNumpad8          KeyCode = 152 // Numeric keypad '8' key
+	KCNumpad9          KeyCode = 153 // Numeric keypad '9' key
+	KCNumpadDivide     KeyCode = 154 // Numeric keypad '/' key (for division)
+	KCNumpadMultiply   KeyCode = 155 // Numeric keypad '*' key (for multiplication)
+	KCNumpadSubtract   KeyCode = 156 // Numeric keypad '-' key (for subtraction)
+	KCNumpadAdd        KeyCode = 157 // Numeric keypad '+' key (for addition)
+	KCNumpadDot        KeyCode = 158 // Numeric keypad '.' key (for decimals or digit grouping)
+	KCNumpadComma      KeyCode = 159 // Numeric keypad ',' key (for decimals or digit grouping)
+	KCNumpadEnter      KeyCode = 160 // Numeric keypad Enter key
+	KCNumpadEquals     KeyCode = 161 // Numeric keypad 'KeyCode =' key
+	KCNumpadLeftParen  KeyCode = 162 // Numeric keypad '(' key
+	KCNumpadRightParen KeyCode = 163 // Numeric keypad ')' key
+
+	// KCVolumeMute Volume Mute key.
+	// Mutes the speaker, unlike `KCMute`.
+	// This key should normally be implemented as a toggle such that the first press
+	// mutes the speaker and the second press restores the original volume.
+	KCVolumeMute KeyCode = 164
+
+	// KCInfo Info key.
+	// Common on TV remotes to show additional information related to what is
+	// currently being viewed.
+	KCInfo KeyCode = 165
+
+	// KCChannelUp Channel up key.
+	// On TV remotes, increments the television channel.
+	KCChannelUp KeyCode = 166
+
+	// KCChannelDown Channel down key.
+	// On TV remotes, decrements the television channel.
+	KCChannelDown KeyCode = 167
+
+	// KCZoomIn Zoom in key.
+	KCZoomIn KeyCode = 168
+
+	// KCZoomOut Zoom out key.
+	KCZoomOut KeyCode = 169
+
+	// KCTv TV key.
+	// On TV remotes, switches to viewing live TV.
+	KCTv KeyCode = 170
+
+	// KCWindow Window key.
+	// On TV remotes, toggles picture-in-picture mode or other windowing functions.
+	// On Android Wear devices, triggers a display offset.
+	KCWindow KeyCode = 171
+
+	// KCGuide Guide key.
+	// On TV remotes, shows a programming guide.
+	KCGuide KeyCode = 172
+
+	// KCDvr DVR key.
+	// On some TV remotes, switches to a DVR mode for recorded shows.
+	KCDvr KeyCode = 173
+
+	// KCBookmark Bookmark key.
+	// On some TV remotes, bookmarks content or web pages.
+	KCBookmark KeyCode = 174
+
+	// KCCaptions Toggle captions key.
+	// Switches the mode for closed-captioning text, for example during television shows.
+	KCCaptions KeyCode = 175
+
+	// KCSettings Settings key.
+	// Starts the system settings activity.
+	KCSettings KeyCode = 176
+
+	// KCTvPower TV power key.
+	// On TV remotes, toggles the power on a television screen.
+	KCTvPower KeyCode = 177
+
+	// KCTvInput TV input key.
+	// On TV remotes, switches the input on a television screen.
+	KCTvInput KeyCode = 178
+
+	// KCStbPower Set-top-box power key.
+	// On TV remotes, toggles the power on an external Set-top-box.
+	KCStbPower KeyCode = 179
+
+	// KCStbInput Set-top-box input key.
+	// On TV remotes, switches the input mode on an external Set-top-box.
+	KCStbInput KeyCode = 180
+
+	// KCAvrPower A/V Receiver power key.
+	// On TV remotes, toggles the power on an external A/V Receiver.
+	KCAvrPower KeyCode = 181
+
+	// KCAvrInput A/V Receiver input key.
+	// On TV remotes, switches the input mode on an external A/V Receiver.
+	KCAvrInput KeyCode = 182
+
+	// KCProgRed Red "programmable" key.
+	// On TV remotes, acts as a contextual/programmable key.
+	KCProgRed KeyCode = 183
+
+	// KCProgGreen Green "programmable" key.
+	// On TV remotes, actsas a contextual/programmable key.
+	KCProgGreen KeyCode = 184
+
+	// KCProgYellow Yellow "programmable" key.
+	// On TV remotes, acts as a contextual/programmable key.
+	KCProgYellow KeyCode = 185
+
+	// KCProgBlue Blue "programmable" key.
+	// On TV remotes, acts as a contextual/programmable key.
+	KCProgBlue KeyCode = 186
+
+	// KCAppSwitch App switch key.
+	// Should bring up the application switcher dialog.
+	KCAppSwitch KeyCode = 187
+
+	KCButton1  KeyCode = 188 // Generic Game Pad Button #1
+	KCButton2  KeyCode = 189 // Generic Game Pad Button #2
+	KCButton3  KeyCode = 190 // Generic Game Pad Button #3
+	KCButton4  KeyCode = 191 // Generic Game Pad Button #4
+	KCButton5  KeyCode = 192 // Generic Game Pad Button #5
+	KCButton6  KeyCode = 193 // Generic Game Pad Button #6
+	KCButton7  KeyCode = 194 // Generic Game Pad Button #7
+	KCButton8  KeyCode = 195 // Generic Game Pad Button #8
+	KCButton9  KeyCode = 196 // Generic Game Pad Button #9
+	KCButton10 KeyCode = 197 // Generic Game Pad Button #10
+	KCButton11 KeyCode = 198 // Generic Game Pad Button #11
+	KCButton12 KeyCode = 199 // Generic Game Pad Button #12
+	KCButton13 KeyCode = 200 // Generic Game Pad Button #13
+	KCButton14 KeyCode = 201 // Generic Game Pad Button #14
+	KCButton15 KeyCode = 202 // Generic Game Pad Button #15
+	KCButton16 KeyCode = 203 // Generic Game Pad Button #16
+
+	// KCLanguageSwitch Language Switch key.
+	// Toggles the current input language such as switching between English and Japanese on
+	// a QWERTY keyboard.  On some devices, the same function may be performed by
+	// pressing Shift+Spacebar.
+	KCLanguageSwitch KeyCode = 204
+
+	// Manner Mode key.
+	// Toggles silent or vibrate mode on and off to make the device behave more politely
+	// in certain settings such as on a crowded train.  On some devices, the key may only
+	// operate when long-pressed.
+	KCMannerMode KeyCode = 205
+
+	// 3D Mode key.
+	// Toggles the display between 2D and 3D mode.
+	KC3dMode KeyCode = 206
+
+	// Contacts special function key.
+	// Used to launch an address book application.
+	KCContacts KeyCode = 207
+
+	// Calendar special function key.
+	// Used to launch a calendar application.
+	KCCalendar KeyCode = 208
+
+	// Music special function key.
+	// Used to launch a music player application.
+	KCMusic KeyCode = 209
+
+	// Calculator special function key.
+	// Used to launch a calculator application.
+	KCCalculator KeyCode = 210
+
+	// Japanese full-width / half-width key.
+	KCZenkakuHankaku KeyCode = 211
+
+	// Japanese alphanumeric key.
+	KCEisu KeyCode = 212
+
+	// Japanese non-conversion key.
+	KCMuhenkan KeyCode = 213
+
+	// Japanese conversion key.
+	KCHenkan KeyCode = 214
+
+	// Japanese katakana / hiragana key.
+	KCKatakanaHiragana KeyCode = 215
+
+	// Japanese Yen key.
+	KCYen KeyCode = 216
+
+	// Japanese Ro key.
+	KCRo KeyCode = 217
+
+	// Japanese kana key.
+	KCKana KeyCode = 218
+
+	// Assist key.
+	// Launches the global assist activity.  Not delivered to applications.
+	KCAssist KeyCode = 219
+
+	// Brightness Down key.
+	// Adjusts the screen brightness down.
+	KCBrightnessDown KeyCode = 220
+
+	// Brightness Up key.
+	// Adjusts the screen brightness up.
+	KCBrightnessUp KeyCode = 221
+
+	// Audio Track key.
+	// Switches the audio tracks.
+	KCMediaAudioTrack KeyCode = 222
+
+	// Sleep key.
+	// Puts the device to sleep.  Behaves somewhat like {@link #KEYCODE_POWER} but it
+	// has no effect if the device is already asleep.
+	KCSleep KeyCode = 223
+
+	// Wakeup key.
+	// Wakes up the device.  Behaves somewhat like {@link #KEYCODE_POWER} but it
+	// has no effect if the device is already awake.
+	KCWakeup KeyCode = 224
+
+	// Pairing key.
+	// Initiates peripheral pairing mode. Useful for pairing remote control
+	// devices or game controllers, especially if no other input mode is
+	// available.
+	KCPairing KeyCode = 225
+
+	// Media Top Menu key.
+	// Goes to the top of media menu.
+	KCMediaTopMenu KeyCode = 226
+
+	// '11' key.
+	KC11 KeyCode = 227
+
+	// '12' key.
+	KC12 KeyCode = 228
+
+	// Last Channel key.
+	// Goes to the last viewed channel.
+	KCLastChannel KeyCode = 229
+
+	// TV data service key.
+	// Displays data services like weather, sports.
+	KCTvDataService KeyCode = 230
+
+	// Voice Assist key.
+	// Launches the global voice assist activity. Not delivered to applications.
+	KCVoiceAssist KeyCode = 231
+
+	// Radio key.
+	// Toggles TV service / Radio service.
+	KCTvRadioService KeyCode = 232
+
+	// Teletext key.
+	// Displays Teletext service.
+	KCTvTeletext KeyCode = 233
+
+	// Number entry key.
+	// Initiates to enter multi-digit channel nubmber when each digit key is assigned
+	// for selecting separate channel. Corresponds to Number Entry Mode (0x1D) of CEC
+	// User Control Code.
+	KCTvNumberEntry KeyCode = 234
+
+	// Analog Terrestrial key.
+	// Switches to analog terrestrial broadcast service.
+	KCTvTerrestrialAnalog KeyCode = 235
+
+	// Digital Terrestrial key.
+	// Switches to digital terrestrial broadcast service.
+	KCTvTerrestrialDigital KeyCode = 236
+
+	// Satellite key.
+	// Switches to digital satellite broadcast service.
+	KCTvSatellite KeyCode = 237
+
+	// BS key.
+	// Switches to BS digital satellite broadcasting service available in Japan.
+	KCTvSatelliteBs KeyCode = 238
+
+	// CS key.
+	// Switches to CS digital satellite broadcasting service available in Japan.
+	KCTvSatelliteCs KeyCode = 239
+
+	// BS/CS key.
+	// Toggles between BS and CS digital satellite services.
+	KCTvSatelliteService KeyCode = 240
+
+	// Toggle Network key.
+	// Toggles selecting broacast services.
+	KCTvNetwork KeyCode = 241
+
+	// Antenna/Cable key.
+	// Toggles broadcast input source between antenna and cable.
+	KCTvAntennaCable KeyCode = 242
+
+	// HDMI #1 key.
+	// Switches to HDMI input #1.
+	KCTvInputHdmi1 KeyCode = 243
+
+	// HDMI #2 key.
+	// Switches to HDMI input #2.
+	KCTvInputHdmi2 KeyCode = 244
+
+	// HDMI #3 key.
+	// Switches to HDMI input #3.
+	KCTvInputHdmi3 KeyCode = 245
+
+	// HDMI #4 key.
+	// Switches to HDMI input #4.
+	KCTvInputHdmi4 KeyCode = 246
+
+	// Composite #1 key.
+	// Switches to composite video input #1.
+	KCTvInputComposite1 KeyCode = 247
+
+	// Composite #2 key.
+	// Switches to composite video input #2.
+	KCTvInputComposite2 KeyCode = 248
+
+	// Component #1 key.
+	// Switches to component video input #1.
+	KCTvInputComponent1 KeyCode = 249
+
+	// Component #2 key.
+	// Switches to component video input #2.
+	KCTvInputComponent2 KeyCode = 250
+
+	// VGA #1 key.
+	// Switches to VGA (analog RGB) input #1.
+	KCTvInputVga1 KeyCode = 251
+
+	// Audio description key.
+	// Toggles audio description off / on.
+	KCTvAudioDescription KeyCode = 252
+
+	// Audio description mixing volume up key.
+	// Louden audio description volume as compared with normal audio volume.
+	KCTvAudioDescriptionMixUp KeyCode = 253
+
+	// Audio description mixing volume down key.
+	// Lessen audio description volume as compared with normal audio volume.
+	KCTvAudioDescriptionMixDown KeyCode = 254
+
+	// Zoom mode key.
+	// Changes Zoom mode (Normal, Full, Zoom, Wide-zoom, etc.)
+	KCTvZoomMode KeyCode = 255
+
+	// Contents menu key.
+	// Goes to the title list. Corresponds to Contents Menu (0x0B) of CEC User Control
+	// Code
+	KCTvContentsMenu KeyCode = 256
+
+	// Media context menu key.
+	// Goes to the context menu of media contents. Corresponds to Media Context-sensitive
+	// Menu (0x11) of CEC User Control Code.
+	KCTvMediaContextMenu KeyCode = 257
+
+	// Timer programming key.
+	// Goes to the timer recording menu. Corresponds to Timer Programming (0x54) of
+	// CEC User Control Code.
+	KCTvTimerProgramming KeyCode = 258
+
+	// Help key.
+	KCHelp KeyCode = 259
+
+	// Navigate to previous key.
+	// Goes backward by one item in an ordered collection of items.
+	KCNavigatePrevious KeyCode = 260
+
+	// Navigate to next key.
+	// Advances to the next item in an ordered collection of items.
+	KCNavigateNext KeyCode = 261
+
+	// Navigate in key.
+	// Activates the item that currently has focus or expands to the next level of a navigation
+	// hierarchy.
+	KCNavigateIn KeyCode = 262
+
+	// Navigate out key.
+	// Backs out one level of a navigation hierarchy or collapses the item that currently has
+	// focus.
+	KCNavigateOut KeyCode = 263
+
+	// Primary stem key for Wear
+	// Main power/reset button on watch.
+	KCStemPrimary KeyCode = 264
+
+	// Generic stem key 1 for Wear
+	KCStem1 KeyCode = 265
+
+	// Generic stem key 2 for Wear
+	KCStem2 KeyCode = 266
+
+	// Generic stem key 3 for Wear
+	KCStem3 KeyCode = 267
+
+	// Directional Pad Up-Left
+	KCDPadUpLeft KeyCode = 268
+
+	// Directional Pad Down-Left
+	KCDPadDownLeft KeyCode = 269
+
+	// Directional Pad Up-Right
+	KCDPadUpRight KeyCode = 270
+
+	// Directional Pad Down-Right
+	KCDPadDownRight KeyCode = 271
+
+	// Skip forward media key.
+	KCMediaSkipForward KeyCode = 272
+
+	// Skip backward media key.
+	KCMediaSkipBackward KeyCode = 273
+
+	// Step forward media key.
+	// Steps media forward, one frame at a time.
+	KCMediaStepForward KeyCode = 274
+
+	// Step backward media key.
+	// Steps media backward, one frame at a time.
+	KCMediaStepBackward KeyCode = 275
+
+	// put device to sleep unless a wakelock is held.
+	KCSoftSleep KeyCode = 276
+
+	// Cut key.
+	KCCut KeyCode = 277
+
+	// Copy key.
+	KCCopy KeyCode = 278
+
+	// Paste key.
+	KCPaste KeyCode = 279
+
+	// Consumed by the system for navigation up
+	KCSystemNavigationUp KeyCode = 280
+
+	// Consumed by the system for navigation down
+	KCSystemNavigationDown KeyCode = 281
+
+	// Consumed by the system for navigation left*/
+	KCSystemNavigationLeft KeyCode = 282
+
+	// Consumed by the system for navigation right
+	KCSystemNavigationRight KeyCode = 283
+
+	// Show all apps
+	KCAllApps KeyCode = 284
+
+	// Refresh key.
+	KCRefresh KeyCode = 285
+)
diff --git a/hrp/pkg/uixt/client.go b/hrp/pkg/uixt/client.go
new file mode 100644
index 000000000..5ebd93096
--- /dev/null
+++ b/hrp/pkg/uixt/client.go
@@ -0,0 +1,104 @@
+package uixt
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"io"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"net/url"
+	"path"
+	"strings"
+	"time"
+
+	"github.com/rs/zerolog/log"
+)
+
+type Driver struct {
+	urlPrefix *url.URL
+	sessionId string
+	client    *http.Client
+}
+
+func (wd *Driver) concatURL(u *url.URL, elem ...string) string {
+	var tmp *url.URL
+	if u == nil {
+		u = wd.urlPrefix
+	}
+	tmp, _ = url.Parse(u.String())
+	tmp.Path = path.Join(append([]string{u.Path}, elem...)...)
+	return tmp.String()
+}
+
+func (wd *Driver) httpGET(pathElem ...string) (rawResp rawResponse, err error) {
+	return wd.httpRequest(http.MethodGet, wd.concatURL(nil, pathElem...), nil)
+}
+
+func (wd *Driver) httpPOST(data interface{}, pathElem ...string) (rawResp rawResponse, err error) {
+	var bsJSON []byte = nil
+	if data != nil {
+		if bsJSON, err = json.Marshal(data); err != nil {
+			return nil, err
+		}
+	}
+	return wd.httpRequest(http.MethodPost, wd.concatURL(nil, pathElem...), bsJSON)
+}
+
+func (wd *Driver) httpDELETE(pathElem ...string) (rawResp rawResponse, err error) {
+	return wd.httpRequest(http.MethodDelete, wd.concatURL(nil, pathElem...), nil)
+}
+
+func (wd *Driver) httpRequest(method string, rawURL string, rawBody []byte) (rawResp rawResponse, err error) {
+	log.Debug().Str("method", method).Str("url", rawURL).Str("body", string(rawBody)).Msg("request driver agent")
+
+	var req *http.Request
+	if req, err = http.NewRequest(method, rawURL, bytes.NewBuffer(rawBody)); err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/json;charset=UTF-8")
+	req.Header.Set("Accept", "application/json")
+
+	start := time.Now()
+	var resp *http.Response
+	if resp, err = wd.client.Do(req); err != nil {
+		return nil, err
+	}
+	defer func() {
+		// https://github.com/etcd-io/etcd/blob/v3.3.25/pkg/httputil/httputil.go#L16-L22
+		_, _ = io.Copy(ioutil.Discard, resp.Body)
+		_ = resp.Body.Close()
+	}()
+
+	rawResp, err = ioutil.ReadAll(resp.Body)
+	logger := log.Debug().Int("statusCode", resp.StatusCode).Str("duration", time.Since(start).String())
+	if !strings.HasSuffix(rawURL, "screenshot") {
+		// avoid printing screenshot data
+		logger.Str("response", string(rawResp))
+	}
+	logger.Msg("get driver agent response")
+	if err != nil {
+		return nil, err
+	}
+
+	if err = rawResp.checkErr(); err != nil {
+		if resp.StatusCode == http.StatusOK {
+			return rawResp, nil
+		}
+		return nil, err
+	}
+
+	return
+}
+
+func convertToHTTPClient(conn net.Conn) *http.Client {
+	return &http.Client{
+		Transport: &http.Transport{
+			DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+				return conn, nil
+			},
+		},
+		Timeout: 0,
+	}
+}
diff --git a/hrp/pkg/uixt/drag.go b/hrp/pkg/uixt/drag.go
new file mode 100644
index 000000000..31d33b1dd
--- /dev/null
+++ b/hrp/pkg/uixt/drag.go
@@ -0,0 +1,30 @@
+package uixt
+
+func (dExt *DriverExt) Drag(pathname string, toX, toY int, pressForDuration ...float64) (err error) {
+	return dExt.DragFloat(pathname, float64(toX), float64(toY), pressForDuration...)
+}
+
+func (dExt *DriverExt) DragFloat(pathname string, toX, toY float64, pressForDuration ...float64) (err error) {
+	return dExt.DragOffsetFloat(pathname, toX, toY, 0.5, 0.5, pressForDuration...)
+}
+
+func (dExt *DriverExt) DragOffset(pathname string, toX, toY int, xOffset, yOffset float64, pressForDuration ...float64) (err error) {
+	return dExt.DragOffsetFloat(pathname, float64(toX), float64(toY), xOffset, yOffset, pressForDuration...)
+}
+
+func (dExt *DriverExt) DragOffsetFloat(pathname string, toX, toY, xOffset, yOffset float64, pressForDuration ...float64) (err error) {
+	if len(pressForDuration) == 0 {
+		pressForDuration = []float64{1.0}
+	}
+
+	var x, y, width, height float64
+	if x, y, width, height, err = dExt.FindUIRectInUIKit(pathname); err != nil {
+		return err
+	}
+
+	fromX := x + width*xOffset
+	fromY := y + height*yOffset
+
+	return dExt.Driver.DragFloat(fromX, fromY, toX, toY,
+		WithDataPressDuration(pressForDuration[0]))
+}
diff --git a/hrp/pkg/uixt/ext.go b/hrp/pkg/uixt/ext.go
new file mode 100644
index 000000000..f7b353bf3
--- /dev/null
+++ b/hrp/pkg/uixt/ext.go
@@ -0,0 +1,683 @@
+package uixt
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"image"
+	"image/jpeg"
+	"image/png"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+)
+
+type MobileMethod string
+
+const (
+	AppInstall          MobileMethod = "install"
+	AppUninstall        MobileMethod = "uninstall"
+	AppStart            MobileMethod = "app_start"
+	AppLaunch           MobileMethod = "app_launch"            // 等待 app 打开并堵塞到 app 首屏加载完成，可以传入 app 的启动参数、环境变量
+	AppLaunchUnattached MobileMethod = "app_launch_unattached" // 只负责通知打开 app，不堵塞等待，不可传入启动参数
+	AppTerminate        MobileMethod = "app_terminate"
+	AppStop             MobileMethod = "app_stop"
+	CtlScreenShot       MobileMethod = "screenshot"
+	CtlSleep            MobileMethod = "sleep"
+	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"
+	AssertionExists    string = "exists"
+	AssertionNotExists string = "not_exists"
+
+	// UI handling
+	ACTION_Home        MobileMethod = "home"
+	ACTION_TapXY       MobileMethod = "tap_xy"
+	ACTION_TapAbsXY    MobileMethod = "tap_abs_xy"
+	ACTION_TapByOCR    MobileMethod = "tap_ocr"
+	ACTION_TapByCV     MobileMethod = "tap_cv"
+	ACTION_Tap         MobileMethod = "tap"
+	ACTION_DoubleTapXY MobileMethod = "double_tap_xy"
+	ACTION_DoubleTap   MobileMethod = "double_tap"
+	ACTION_Swipe       MobileMethod = "swipe"
+	ACTION_Input       MobileMethod = "input"
+
+	// custom actions
+	ACTION_SwipeToTapApp   MobileMethod = "swipe_to_tap_app"   // swipe left & right to find app and tap
+	ACTION_SwipeToTapText  MobileMethod = "swipe_to_tap_text"  // swipe up & down to find text and tap
+	ACTION_SwipeToTapTexts MobileMethod = "swipe_to_tap_texts" // swipe up & down to find text and tap
+)
+
+type MobileAction struct {
+	Method MobileMethod `json:"method,omitempty" yaml:"method,omitempty"`
+	Params interface{}  `json:"params,omitempty" yaml:"params,omitempty"`
+
+	Identifier          string      `json:"identifier,omitempty" yaml:"identifier,omitempty"`                     // used to identify the action in log
+	MaxRetryTimes       int         `json:"max_retry_times,omitempty" yaml:"max_retry_times,omitempty"`           // max retry times
+	WaitTime            float64     `json:"wait_time,omitempty" yaml:"wait_time,omitempty"`                       // wait time between swipe and ocr, unit: second
+	Direction           interface{} `json:"direction,omitempty" yaml:"direction,omitempty"`                       // used by swipe to tap text or app
+	Scope               []float64   `json:"scope,omitempty" yaml:"scope,omitempty"`                               // used by ocr to get text position in the scope
+	Index               int         `json:"index,omitempty" yaml:"index,omitempty"`                               // index of the target element, should start from 1
+	Timeout             int         `json:"timeout,omitempty" yaml:"timeout,omitempty"`                           // TODO: wait timeout in seconds for mobile action
+	IgnoreNotFoundError bool        `json:"ignore_NotFoundError,omitempty" yaml:"ignore_NotFoundError,omitempty"` // ignore error if target element not found
+	Text                string      `json:"text,omitempty" yaml:"text,omitempty"`
+	ID                  string      `json:"id,omitempty" yaml:"id,omitempty"`
+	Description         string      `json:"description,omitempty" yaml:"description,omitempty"`
+}
+
+type ActionOption func(o *MobileAction)
+
+func WithIdentifier(identifier string) ActionOption {
+	return func(o *MobileAction) {
+		o.Identifier = identifier
+	}
+}
+
+func WithIndex(index int) ActionOption {
+	return func(o *MobileAction) {
+		o.Index = index
+	}
+}
+
+func WithWaitTime(sec float64) ActionOption {
+	return func(o *MobileAction) {
+		o.WaitTime = sec
+	}
+}
+
+// WithDirection inputs direction (up, down, left, right)
+func WithDirection(direction string) ActionOption {
+	return func(o *MobileAction) {
+		o.Direction = direction
+	}
+}
+
+// WithCustomDirection inputs sx, sy, ex, ey
+func WithCustomDirection(sx, sy, ex, ey float64) ActionOption {
+	return func(o *MobileAction) {
+		o.Direction = []float64{sx, sy, ex, ey}
+	}
+}
+
+// WithScope inputs area of [(x1,y1), (x2,y2)]
+func WithScope(x1, y1, x2, y2 float64) ActionOption {
+	return func(o *MobileAction) {
+		o.Scope = []float64{x1, y1, x2, y2}
+	}
+}
+
+func WithText(text string) ActionOption {
+	return func(o *MobileAction) {
+		o.Text = text
+	}
+}
+
+func WithID(id string) ActionOption {
+	return func(o *MobileAction) {
+		o.ID = id
+	}
+}
+
+func WithDescription(description string) ActionOption {
+	return func(o *MobileAction) {
+		o.Description = description
+	}
+}
+
+func WithMaxRetryTimes(maxRetryTimes int) ActionOption {
+	return func(o *MobileAction) {
+		o.MaxRetryTimes = maxRetryTimes
+	}
+}
+
+func WithTimeout(timeout int) ActionOption {
+	return func(o *MobileAction) {
+		o.Timeout = timeout
+	}
+}
+
+func WithIgnoreNotFoundError(ignoreError bool) ActionOption {
+	return func(o *MobileAction) {
+		o.IgnoreNotFoundError = ignoreError
+	}
+}
+
+// TemplateMatchMode is the type of the template matching operation.
+type TemplateMatchMode int
+
+type CVArgs struct {
+	matchMode TemplateMatchMode
+	threshold float64
+}
+
+type CVOption func(*CVArgs)
+
+func WithTemplateMatchMode(mode TemplateMatchMode) CVOption {
+	return func(args *CVArgs) {
+		args.matchMode = mode
+	}
+}
+
+func WithThreshold(threshold float64) CVOption {
+	return func(args *CVArgs) {
+		args.threshold = threshold
+	}
+}
+
+type DriverExt struct {
+	UUID            string // ios udid or android serial
+	Driver          WebDriver
+	windowSize      Size
+	frame           *bytes.Buffer
+	doneMjpegStream chan bool
+	scale           float64
+	StartTime       time.Time     // used to associate screenshots name
+	ScreenShots     []string      // save screenshots path
+	perfStop        chan struct{} // stop performance monitor
+	perfData        []string      // save perf data
+
+	CVArgs
+}
+
+func extend(driver WebDriver) (dExt *DriverExt, err error) {
+	dExt = &DriverExt{Driver: driver}
+	dExt.doneMjpegStream = make(chan bool, 1)
+
+	// get device window size
+	dExt.windowSize, err = dExt.Driver.WindowSize()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get windows size")
+	}
+
+	if dExt.scale, err = dExt.Driver.Scale(); err != nil {
+		return nil, err
+	}
+
+	return dExt, nil
+}
+
+func (dExt *DriverExt) GetPerfData() []string {
+	if dExt.perfStop == nil {
+		return nil
+	}
+	close(dExt.perfStop)
+	return dExt.perfData
+}
+
+func (dExt *DriverExt) takeScreenShot() (raw *bytes.Buffer, err error) {
+	// wait for action done
+	time.Sleep(500 * time.Millisecond)
+
+	// iOS 优先使用 MJPEG 流进行截图，性能最优
+	// 如果 MJPEG 流未开启，则使用 WebDriver 的截图接口
+	if dExt.frame != nil {
+		return dExt.frame, nil
+	}
+	if raw, err = dExt.Driver.Screenshot(); err != nil {
+		log.Error().Err(err).Msg("takeScreenShot failed")
+		return nil, err
+	}
+	return raw, nil
+}
+
+// saveScreenShot saves image file to $CWD/screenshots/ folder
+func (dExt *DriverExt) saveScreenShot(raw *bytes.Buffer, fileName string) (string, error) {
+	img, format, err := image.Decode(raw)
+	if err != nil {
+		return "", errors.Wrap(err, "decode screenshot image failed")
+	}
+
+	dir, _ := os.Getwd()
+	screenshotsDir := filepath.Join(dir, "screenshots")
+	if err = os.MkdirAll(screenshotsDir, os.ModePerm); err != nil {
+		return "", errors.Wrap(err, "create screenshots directory failed")
+	}
+	screenshotPath := filepath.Join(screenshotsDir,
+		fmt.Sprintf("%s.%s", fileName, format))
+
+	file, err := os.Create(screenshotPath)
+	if err != nil {
+		return "", errors.Wrap(err, "create screenshot image file failed")
+	}
+	defer func() {
+		_ = file.Close()
+	}()
+
+	switch format {
+	case "png":
+		err = png.Encode(file, img)
+	case "jpeg":
+		err = jpeg.Encode(file, img, nil)
+	default:
+		return "", fmt.Errorf("unsupported image format: %s", format)
+	}
+	if err != nil {
+		return "", errors.Wrap(err, "encode screenshot image failed")
+	}
+
+	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")
+	}
+
+	path, err := dExt.saveScreenShot(raw, fileName)
+	if err != nil {
+		return "", errors.Wrap(err, "save screenshot failed")
+	}
+	return path, nil
+}
+
+// isPathExists returns true if path exists, whether path is file or dir
+func isPathExists(path string) bool {
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		return false
+	}
+	return true
+}
+
+func (dExt *DriverExt) FindUIElement(param string) (ele WebElement, err error) {
+	var selector BySelector
+	if strings.HasPrefix(param, "/") {
+		// xpath
+		selector = BySelector{
+			XPath: param,
+		}
+	} else if strings.HasPrefix(param, "com.") {
+		// name
+		selector = BySelector{
+			ResourceIdID: param,
+		}
+	} else {
+		// name
+		selector = BySelector{
+			LinkText: NewElementAttribute().WithName(param),
+		}
+	}
+
+	return dExt.Driver.FindElement(selector)
+}
+
+func (dExt *DriverExt) FindUIRectInUIKit(search string, options ...DataOption) (x, y, width, height float64, err error) {
+	// click on text, using OCR
+	if !isPathExists(search) {
+		return dExt.FindTextByOCR(search, options...)
+	}
+	// click on image, using opencv
+	return dExt.FindImageRectInUIKit(search, options...)
+}
+
+func (dExt *DriverExt) MappingToRectInUIKit(rect image.Rectangle) (x, y, width, height float64) {
+	x, y = float64(rect.Min.X)/dExt.scale, float64(rect.Min.Y)/dExt.scale
+	width, height = float64(rect.Dx())/dExt.scale, float64(rect.Dy())/dExt.scale
+	return
+}
+
+func (dExt *DriverExt) PerformTouchActions(touchActions *TouchActions) error {
+	return dExt.Driver.PerformAppiumTouchActions(touchActions)
+}
+
+func (dExt *DriverExt) PerformActions(actions *W3CActions) error {
+	return dExt.Driver.PerformW3CActions(actions)
+}
+
+func (dExt *DriverExt) IsNameExist(name string) bool {
+	selector := BySelector{
+		LinkText: NewElementAttribute().WithName(name),
+	}
+	_, err := dExt.Driver.FindElement(selector)
+	return err == nil
+}
+
+func (dExt *DriverExt) IsLabelExist(label string) bool {
+	selector := BySelector{
+		LinkText: NewElementAttribute().WithLabel(label),
+	}
+	_, err := dExt.Driver.FindElement(selector)
+	return err == nil
+}
+
+func (dExt *DriverExt) IsOCRExist(text string) bool {
+	_, _, _, _, err := dExt.FindTextByOCR(text)
+	return err == nil
+}
+
+func (dExt *DriverExt) IsImageExist(text string) bool {
+	_, _, _, _, err := dExt.FindImageRectInUIKit(text)
+	return err == nil
+}
+
+var errActionNotImplemented = errors.New("UI action not implemented")
+
+func (dExt *DriverExt) DoAction(action MobileAction) error {
+	log.Info().Str("method", string(action.Method)).Interface("params", action.Params).Msg("start UI action")
+
+	switch action.Method {
+	case AppInstall:
+		// TODO
+		return errActionNotImplemented
+	case AppLaunch:
+		if bundleId, ok := action.Params.(string); ok {
+			return dExt.Driver.AppLaunch(bundleId)
+		}
+		return fmt.Errorf("invalid %s params, should be bundleId(string), got %v",
+			AppLaunch, action.Params)
+	case AppLaunchUnattached:
+		if bundleId, ok := action.Params.(string); ok {
+			return dExt.Driver.AppLaunchUnattached(bundleId)
+		}
+		return fmt.Errorf("invalid %s params, should be bundleId(string), got %v",
+			AppLaunchUnattached, action.Params)
+	case ACTION_SwipeToTapApp:
+		if appName, ok := action.Params.(string); ok {
+			return dExt.swipeToTapApp(appName, action)
+		}
+		return fmt.Errorf("invalid %s params, should be app name(string), got %v",
+			ACTION_SwipeToTapApp, action.Params)
+	case ACTION_SwipeToTapText:
+		// TODO: merge to LoopUntil
+		if text, ok := action.Params.(string); ok {
+			if len(action.Scope) != 4 {
+				action.Scope = []float64{0, 0, 1, 1}
+			}
+
+			identifierOption := WithDataIdentifier(action.Identifier)
+			indexOption := WithDataIndex(action.Index)
+			scopeOption := WithDataScope(dExt.getAbsScope(action.Scope[0], action.Scope[1], action.Scope[2], action.Scope[3]))
+
+			// default to retry 10 times
+			if action.MaxRetryTimes == 0 {
+				action.MaxRetryTimes = 10
+			}
+			maxRetryOption := WithDataMaxRetryTimes(action.MaxRetryTimes)
+			waitTimeOption := WithDataWaitTime(action.WaitTime)
+
+			var point PointF
+			// findTextAction := func(d *DriverExt) error {
+			// 	return nil
+			// }
+			findTextCondition := func(d *DriverExt) error {
+				var err error
+				point, err = d.GetTextXY(text, indexOption, scopeOption)
+				return err
+			}
+			foundTextAction := func(d *DriverExt) error {
+				// tap text
+				return d.TapAbsXY(point.X, point.Y, identifierOption)
+			}
+
+			if action.Direction != nil {
+				return dExt.SwipeUntil(action.Direction, findTextCondition, foundTextAction, maxRetryOption, waitTimeOption)
+			}
+			// swipe until found
+			return dExt.SwipeUntil("up", findTextCondition, foundTextAction, maxRetryOption, waitTimeOption)
+		}
+		return fmt.Errorf("invalid %s params, should be app text(string), got %v",
+			ACTION_SwipeToTapText, action.Params)
+	case ACTION_SwipeToTapTexts:
+		// TODO: merge to LoopUntil
+		if texts, ok := action.Params.([]interface{}); ok {
+			var textList []string
+			for _, t := range texts {
+				textList = append(textList, t.(string))
+			}
+			action.Params = textList
+		}
+		if texts, ok := action.Params.([]string); ok {
+			if len(action.Scope) != 4 {
+				action.Scope = []float64{0, 0, 1, 1}
+			}
+
+			scopeOption := WithDataScope(dExt.getAbsScope(action.Scope[0], action.Scope[1], action.Scope[2], action.Scope[3]))
+			// default to retry 10 times
+			if action.MaxRetryTimes == 0 {
+				action.MaxRetryTimes = 10
+			}
+			maxRetryOption := WithDataMaxRetryTimes(action.MaxRetryTimes)
+			waitTimeOption := WithDataWaitTime(action.WaitTime)
+
+			var point PointF
+			findTexts := func(d *DriverExt) error {
+				var err error
+				points, err := d.GetTextXYs(texts, scopeOption)
+				if err != nil {
+					return err
+				}
+				for _, point = range points {
+					if point != (PointF{X: 0, Y: 0}) {
+						return nil
+					}
+				}
+				return errors.New("failed to find text position")
+			}
+			foundTextAction := func(d *DriverExt) error {
+				// tap text
+				return d.TapAbsXY(point.X, point.Y, WithDataIdentifier(action.Identifier))
+			}
+
+			// default to retry 10 times
+			if action.MaxRetryTimes == 0 {
+				action.MaxRetryTimes = 10
+			}
+
+			if action.Direction != nil {
+				return dExt.SwipeUntil(action.Direction, findTexts, foundTextAction, maxRetryOption, waitTimeOption)
+			}
+			// swipe until found
+			return dExt.SwipeUntil("up", findTexts, foundTextAction, maxRetryOption, waitTimeOption)
+		}
+		return fmt.Errorf("invalid %s params, should be app text([]string), got %v",
+			ACTION_SwipeToTapText, action.Params)
+	case AppTerminate:
+		if bundleId, ok := action.Params.(string); ok {
+			success, err := dExt.Driver.AppTerminate(bundleId)
+			if err != nil {
+				return errors.Wrap(err, "failed to terminate app")
+			}
+			if !success {
+				log.Warn().Str("bundleId", bundleId).Msg("app was not running")
+			}
+			return nil
+		}
+		return fmt.Errorf("app_terminate params should be bundleId(string), got %v", action.Params)
+	case ACTION_Home:
+		return dExt.Driver.Homescreen()
+	case ACTION_TapXY:
+		if location, ok := action.Params.([]interface{}); ok {
+			// relative x,y of window size: [0.5, 0.5]
+			if len(location) != 2 {
+				return fmt.Errorf("invalid tap location params: %v", location)
+			}
+			x, _ := location[0].(float64)
+			y, _ := location[1].(float64)
+			return dExt.TapXY(x, y, WithDataIdentifier(action.Identifier))
+		}
+		return fmt.Errorf("invalid %s params: %v", ACTION_TapXY, action.Params)
+	case ACTION_TapAbsXY:
+		if location, ok := action.Params.([]interface{}); ok {
+			// absolute coordinates x,y of window size: [100, 300]
+			if len(location) != 2 {
+				return fmt.Errorf("invalid tap location params: %v", location)
+			}
+			x, _ := location[0].(float64)
+			y, _ := location[1].(float64)
+			return dExt.TapAbsXY(x, y, WithDataIdentifier(action.Identifier))
+		}
+		return fmt.Errorf("invalid %s params: %v", ACTION_TapAbsXY, action.Params)
+	case ACTION_Tap:
+		if param, ok := action.Params.(string); ok {
+			return dExt.Tap(param, WithDataIdentifier(action.Identifier), WithDataIgnoreNotFoundError(true), WithDataIndex(action.Index))
+		}
+		return fmt.Errorf("invalid %s params: %v", ACTION_Tap, action.Params)
+	case ACTION_TapByOCR:
+		if ocrText, ok := action.Params.(string); ok {
+			if len(action.Scope) != 4 {
+				action.Scope = []float64{0, 0, 1, 1}
+			}
+
+			indexOption := WithDataIndex(action.Index)
+			scopeOption := WithDataScope(dExt.getAbsScope(action.Scope[0], action.Scope[1], action.Scope[2], action.Scope[3]))
+			identifierOption := WithDataIdentifier(action.Identifier)
+			IgnoreNotFoundErrorOption := WithDataIgnoreNotFoundError(action.IgnoreNotFoundError)
+			return dExt.TapByOCR(ocrText, identifierOption, IgnoreNotFoundErrorOption, indexOption, scopeOption)
+		}
+		return fmt.Errorf("invalid %s params: %v", ACTION_TapByOCR, action.Params)
+	case ACTION_TapByCV:
+		if imagePath, ok := action.Params.(string); ok {
+			return dExt.TapByCV(imagePath, WithDataIdentifier(action.Identifier), WithDataIgnoreNotFoundError(true), WithDataIndex(action.Index))
+		}
+		return fmt.Errorf("invalid %s params: %v", ACTION_TapByCV, action.Params)
+	case ACTION_DoubleTapXY:
+		if location, ok := action.Params.([]interface{}); ok {
+			// relative x,y of window size: [0.5, 0.5]
+			if len(location) != 2 {
+				return fmt.Errorf("invalid tap location params: %v", location)
+			}
+			x, _ := location[0].(float64)
+			y, _ := location[1].(float64)
+			return dExt.DoubleTapXY(x, y)
+		}
+		return fmt.Errorf("invalid %s params: %v", ACTION_DoubleTapXY, action.Params)
+	case ACTION_DoubleTap:
+		if param, ok := action.Params.(string); ok {
+			return dExt.DoubleTap(param)
+		}
+		return fmt.Errorf("invalid %s params: %v", ACTION_DoubleTap, action.Params)
+	case ACTION_Swipe:
+		identifierOption := WithDataIdentifier(action.Identifier)
+		if positions, ok := action.Params.([]interface{}); ok {
+			// relative fromX, fromY, toX, toY of window size: [0.5, 0.9, 0.5, 0.1]
+			if len(positions) != 4 {
+				return fmt.Errorf("invalid swipe params [fromX, fromY, toX, toY]: %v", positions)
+			}
+			fromX, _ := positions[0].(float64)
+			fromY, _ := positions[1].(float64)
+			toX, _ := positions[2].(float64)
+			toY, _ := positions[3].(float64)
+			return dExt.SwipeRelative(fromX, fromY, toX, toY, identifierOption)
+		}
+		if direction, ok := action.Params.(string); ok {
+			return dExt.SwipeTo(direction, identifierOption)
+		}
+		return fmt.Errorf("invalid %s params: %v", ACTION_Swipe, action.Params)
+	case ACTION_Input:
+		// input text on current active element
+		// append \n to send text with enter
+		// send \b\b\b to delete 3 chars
+		param := fmt.Sprintf("%v", action.Params)
+		options := []DataOption{}
+		if action.Text != "" {
+			options = append(options, WithCustomOption("textview", action.Text))
+		}
+		if action.ID != "" {
+			options = append(options, WithCustomOption("id", action.ID))
+		}
+		if action.Description != "" {
+			options = append(options, WithCustomOption("description", action.Description))
+		}
+		if action.Identifier != "" {
+			options = append(options, WithDataIdentifier(action.Identifier))
+		}
+		return dExt.Driver.Input(param, options...)
+	case CtlSleep:
+		if param, ok := action.Params.(json.Number); ok {
+			seconds, _ := param.Float64()
+			time.Sleep(time.Duration(seconds*1000) * time.Millisecond)
+			return nil
+		} else if param, ok := action.Params.(float64); ok {
+			time.Sleep(time.Duration(param*1000) * time.Millisecond)
+			return nil
+		} else if param, ok := action.Params.(int64); ok {
+			time.Sleep(time.Duration(param) * time.Second)
+			return nil
+		}
+		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("%d_screenshot_%d",
+			dExt.StartTime.Unix(), time.Now().Unix()))
+		if err != nil {
+			return errors.Wrap(err, "take screenshot failed")
+		}
+		log.Info().Str("path", screenshotPath).Msg("take screenshot")
+		dExt.ScreenShots = append(dExt.ScreenShots, screenshotPath)
+		return err
+	case CtlStartCamera:
+		return dExt.Driver.StartCamera()
+	case CtlStopCamera:
+		return dExt.Driver.StopCamera()
+	}
+	return nil
+}
+
+func (dExt *DriverExt) getAbsScope(x1, y1, x2, y2 float64) (int, int, int, int) {
+	return int(x1 * float64(dExt.windowSize.Width) * dExt.scale),
+		int(y1 * float64(dExt.windowSize.Height) * dExt.scale),
+		int(x2 * float64(dExt.windowSize.Width) * dExt.scale),
+		int(y2 * float64(dExt.windowSize.Height) * dExt.scale)
+}
+
+func (dExt *DriverExt) DoValidation(check, assert, expected string, message ...string) bool {
+	var exists bool
+	if assert == AssertionExists {
+		exists = true
+	} else {
+		exists = false
+	}
+	var result bool
+	switch check {
+	case SelectorName:
+		result = (dExt.IsNameExist(expected) == exists)
+	case SelectorLabel:
+		result = (dExt.IsLabelExist(expected) == exists)
+	case SelectorOCR:
+		result = (dExt.IsOCRExist(expected) == exists)
+	case SelectorImage:
+		result = (dExt.IsImageExist(expected) == exists)
+	}
+
+	if !result {
+		if message == nil {
+			message = []string{""}
+		}
+		log.Error().
+			Str("assert", assert).
+			Str("expect", expected).
+			Str("msg", message[0]).
+			Msg("validate UI failed")
+		return false
+	}
+
+	log.Info().
+		Str("assert", assert).
+		Str("expect", expected).
+		Msg("validate UI success")
+	return true
+}
+
+func checkErr(t *testing.T, err error, msg ...string) {
+	if err != nil {
+		if len(msg) == 0 {
+			t.Fatal(err)
+		} else {
+			t.Fatal(msg, err)
+		}
+	}
+}
diff --git a/hrp/pkg/uixt/gesture.go b/hrp/pkg/uixt/gesture.go
new file mode 100644
index 000000000..7642425cc
--- /dev/null
+++ b/hrp/pkg/uixt/gesture.go
@@ -0,0 +1,44 @@
+//go:build opencv
+
+package uixt
+
+import (
+	"image"
+	"sort"
+)
+
+func (dExt *DriverExt) GesturePassword(pathname string, password ...int) (err error) {
+	var rects []image.Rectangle
+	if rects, err = dExt.FindAllImageRect(pathname); err != nil {
+		return err
+	}
+
+	sort.Slice(rects, func(i, j int) bool {
+		if rects[i].Min.Y < rects[j].Min.Y {
+			return true
+		} else if rects[i].Min.Y == rects[j].Min.Y {
+			if rects[i].Min.X < rects[j].Min.X {
+				return true
+			}
+		}
+		return false
+	})
+
+	touchActions := NewTouchActions(len(password)*2 + 1)
+	for i := range password {
+		x, y, width, height := dExt.MappingToRectInUIKit(rects[password[i]])
+		x = x + width*0.5
+		y = y + height*0.5
+
+		if i == 0 {
+			touchActions.Press(NewTouchActionPress().WithXYFloat(x, y)).
+				Wait(0.2)
+		} else {
+			touchActions.MoveTo(NewTouchActionMoveTo().WithXYFloat(x, y)).
+				Wait(0.2)
+		}
+	}
+	touchActions.Release()
+
+	return dExt.PerformTouchActions(touchActions)
+}
diff --git a/hrp/pkg/uixt/interface.go b/hrp/pkg/uixt/interface.go
new file mode 100644
index 000000000..547197a4e
--- /dev/null
+++ b/hrp/pkg/uixt/interface.go
@@ -0,0 +1,1180 @@
+package uixt
+
+import (
+	"bytes"
+	"fmt"
+	"math"
+	"reflect"
+	"strconv"
+	"strings"
+	"time"
+)
+
+var (
+	DefaultWaitTimeout  = 60 * time.Second
+	DefaultWaitInterval = 400 * time.Millisecond
+)
+
+type AlertAction string
+
+const (
+	AlertActionAccept  AlertAction = "accept"
+	AlertActionDismiss AlertAction = "dismiss"
+)
+
+type Capabilities map[string]interface{}
+
+func NewCapabilities() Capabilities {
+	return make(Capabilities)
+}
+
+func (caps Capabilities) WithAppLaunchOption(launchOpt AppLaunchOption) Capabilities {
+	for k, v := range launchOpt {
+		caps[k] = v
+	}
+	return caps
+}
+
+// WithDefaultAlertAction
+func (caps Capabilities) WithDefaultAlertAction(alertAction AlertAction) Capabilities {
+	caps["defaultAlertAction"] = alertAction
+	return caps
+}
+
+// WithMaxTypingFrequency
+//  Defaults to `60`.
+func (caps Capabilities) WithMaxTypingFrequency(n int) Capabilities {
+	if n <= 0 {
+		n = 60
+	}
+	caps["maxTypingFrequency"] = n
+	return caps
+}
+
+// WithWaitForIdleTimeout
+//  Defaults to `10`
+func (caps Capabilities) WithWaitForIdleTimeout(second float64) Capabilities {
+	caps["waitForIdleTimeout"] = second
+	return caps
+}
+
+// WithShouldUseTestManagerForVisibilityDetection If set to YES will ask TestManagerDaemon for element visibility
+//  Defaults to  `false`
+func (caps Capabilities) WithShouldUseTestManagerForVisibilityDetection(b bool) Capabilities {
+	caps["shouldUseTestManagerForVisibilityDetection"] = b
+	return caps
+}
+
+// WithShouldUseCompactResponses If set to YES will use compact (standards-compliant) & faster responses
+//  Defaults to `true`
+func (caps Capabilities) WithShouldUseCompactResponses(b bool) Capabilities {
+	caps["shouldUseCompactResponses"] = b
+	return caps
+}
+
+// WithElementResponseAttributes If shouldUseCompactResponses == NO,
+// is the comma-separated list of fields to return with each element.
+//  Defaults to `type,label`.
+func (caps Capabilities) WithElementResponseAttributes(s string) Capabilities {
+	caps["elementResponseAttributes"] = s
+	return caps
+}
+
+// WithShouldUseSingletonTestManager
+//  Defaults to `true`
+func (caps Capabilities) WithShouldUseSingletonTestManager(b bool) Capabilities {
+	caps["shouldUseSingletonTestManager"] = b
+	return caps
+}
+
+// WithDisableAutomaticScreenshots
+//  Defaults to `true`
+func (caps Capabilities) WithDisableAutomaticScreenshots(b bool) Capabilities {
+	caps["disableAutomaticScreenshots"] = b
+	return caps
+}
+
+// WithShouldTerminateApp
+//  Defaults to `true`
+func (caps Capabilities) WithShouldTerminateApp(b bool) Capabilities {
+	caps["shouldTerminateApp"] = b
+	return caps
+}
+
+// WithEventloopIdleDelaySec
+// Delays the invocation of '-[XCUIApplicationProcess setEventLoopHasIdled:]' by the timer interval passed.
+// which is skipped on setting it to zero.
+func (caps Capabilities) WithEventloopIdleDelaySec(second float64) Capabilities {
+	caps["eventloopIdleDelaySec"] = second
+	return caps
+}
+
+type SessionInfo struct {
+	SessionId    string `json:"sessionId"`
+	Capabilities struct {
+		Device             string `json:"device"`
+		BrowserName        string `json:"browserName"`
+		SdkVersion         string `json:"sdkVersion"`
+		CFBundleIdentifier string `json:"CFBundleIdentifier"`
+	} `json:"capabilities"`
+}
+
+type DeviceStatus struct {
+	Message string `json:"message"`
+	State   string `json:"state"`
+	OS      struct {
+		TestmanagerdVersion int    `json:"testmanagerdVersion"`
+		Name                string `json:"name"`
+		SdkVersion          string `json:"sdkVersion"`
+		Version             string `json:"version"`
+	} `json:"os"`
+	IOS struct {
+		IP               string `json:"ip"`
+		SimulatorVersion string `json:"simulatorVersion"`
+	} `json:"ios"`
+	Ready bool `json:"ready"`
+	Build struct {
+		Time                    string `json:"time"`
+		ProductBundleIdentifier string `json:"productBundleIdentifier"`
+	} `json:"build"`
+}
+
+type DeviceInfo struct {
+	TimeZone           string `json:"timeZone"`
+	CurrentLocale      string `json:"currentLocale"`
+	Model              string `json:"model"`
+	UUID               string `json:"uuid"`
+	UserInterfaceIdiom int    `json:"userInterfaceIdiom"`
+	UserInterfaceStyle string `json:"userInterfaceStyle"`
+	Name               string `json:"name"`
+	IsSimulator        bool   `json:"isSimulator"`
+	ThermalState       int    `json:"thermalState"`
+	// ANDROID_ID A 64-bit number (as a hex string) that is uniquely generated when the user
+	// first sets up the device and should remain constant for the lifetime of the user's device. The value
+	// may change if a factory reset is performed on the device.
+	AndroidID string `json:"androidId"`
+	// Build.MANUFACTURER value
+	Manufacturer string `json:"manufacturer"`
+	// Build.BRAND value
+	Brand string `json:"brand"`
+	// Current running OS's API VERSION
+	APIVersion string `json:"apiVersion"`
+	// The current version string, for example "1.0" or "3.4b5"
+	PlatformVersion string `json:"platformVersion"`
+	// the name of the current celluar network carrier
+	CarrierName string `json:"carrierName"`
+	// the real size of the default display
+	RealDisplaySize string `json:"realDisplaySize"`
+	// The logical density of the display in Density Independent Pixel units.
+	DisplayDensity int `json:"displayDensity"`
+	// available networks
+	Networks []networkInfo `json:"networks"`
+	// current system locale
+	Locale    string `json:"locale"`
+	Bluetooth struct {
+		State string `json:"state"`
+	} `json:"bluetooth"`
+}
+
+type networkCapabilities struct {
+	TransportTypes            string `json:"transportTypes"`
+	NetworkCapabilities       string `json:"networkCapabilities"`
+	LinkUpstreamBandwidthKbps int    `json:"linkUpstreamBandwidthKbps"`
+	LinkDownBandwidthKbps     int    `json:"linkDownBandwidthKbps"`
+	SignalStrength            int    `json:"signalStrength"`
+	SSID                      string `json:"SSID"`
+}
+
+type networkInfo struct {
+	Type          int                 `json:"type"`
+	TypeName      string              `json:"typeName"`
+	Subtype       int                 `json:"subtype"`
+	SubtypeName   string              `json:"subtypeName"`
+	IsConnected   bool                `json:"isConnected"`
+	DetailedState string              `json:"detailedState"`
+	State         string              `json:"state"`
+	ExtraInfo     string              `json:"extraInfo"`
+	IsAvailable   bool                `json:"isAvailable"`
+	IsRoaming     bool                `json:"isRoaming"`
+	IsFailover    bool                `json:"isFailover"`
+	Capabilities  networkCapabilities `json:"capabilities"`
+}
+
+type Location struct {
+	AuthorizationStatus int     `json:"authorizationStatus"`
+	Longitude           float64 `json:"longitude"`
+	Latitude            float64 `json:"latitude"`
+	Altitude            float64 `json:"altitude"`
+}
+
+type BatteryInfo struct {
+	// Battery level in range [0.0, 1.0], where 1.0 means 100% charge.
+	Level float64 `json:"level"`
+
+	// Battery state ( 1: on battery, discharging; 2: plugged in, less than 100%, 3: plugged in, at 100% )
+	State BatteryState `json:"state"`
+
+	Status BatteryStatus `json:"status"`
+}
+
+type BatteryState int
+
+const (
+	_                                  = iota
+	BatteryStateUnplugged BatteryState = iota // on battery, discharging
+	BatteryStateCharging                      // plugged in, less than 100%
+	BatteryStateFull                          // plugged in, at 100%
+)
+
+func (v BatteryState) String() string {
+	switch v {
+	case BatteryStateUnplugged:
+		return "On battery, discharging"
+	case BatteryStateCharging:
+		return "Plugged in, less than 100%"
+	case BatteryStateFull:
+		return "Plugged in, at 100%"
+	default:
+		return "UNKNOWN"
+	}
+}
+
+type Size struct {
+	Width  int `json:"width"`
+	Height int `json:"height"`
+}
+
+type Screen struct {
+	StatusBarSize Size    `json:"statusBarSize"`
+	Scale         float64 `json:"scale"`
+}
+
+type AppInfo struct {
+	ProcessArguments struct {
+		Env  interface{}   `json:"env"`
+		Args []interface{} `json:"args"`
+	} `json:"processArguments"`
+	Name string `json:"name"`
+	AppBaseInfo
+}
+
+type AppBaseInfo struct {
+	Pid      int    `json:"pid"`
+	BundleId string `json:"bundleId"`
+}
+
+type AppState int
+
+const (
+	AppStateNotRunning AppState = 1 << iota
+	AppStateRunningBack
+	AppStateRunningFront
+)
+
+func (v AppState) String() string {
+	switch v {
+	case AppStateNotRunning:
+		return "Not Running"
+	case AppStateRunningBack:
+		return "Running (Back)"
+	case AppStateRunningFront:
+		return "Running (Front)"
+	default:
+		return "UNKNOWN"
+	}
+}
+
+// AppLaunchOption Configure app launch parameters
+type AppLaunchOption map[string]interface{}
+
+func NewAppLaunchOption() AppLaunchOption {
+	return make(AppLaunchOption)
+}
+
+func (opt AppLaunchOption) WithBundleId(bundleId string) AppLaunchOption {
+	opt["bundleId"] = bundleId
+	return opt
+}
+
+// WithShouldWaitForQuiescence whether to wait for quiescence on application startup
+//  Defaults to `true`
+func (opt AppLaunchOption) WithShouldWaitForQuiescence(b bool) AppLaunchOption {
+	opt["shouldWaitForQuiescence"] = b
+	return opt
+}
+
+// WithArguments The optional array of application command line arguments.
+// The arguments are going to be applied if the application was not running before.
+func (opt AppLaunchOption) WithArguments(args []string) AppLaunchOption {
+	opt["arguments"] = args
+	return opt
+}
+
+// WithEnvironment The optional dictionary of environment variables for the application, which is going to be executed.
+// The environment variables are going to be applied if the application was not running before.
+func (opt AppLaunchOption) WithEnvironment(env map[string]string) AppLaunchOption {
+	opt["environment"] = env
+	return opt
+}
+
+func (opt AppLaunchOption) WithAndroidBySelector(waitForComplete ...AndroidBySelector) AppLaunchOption {
+	opt["androidBySelector"] = waitForComplete
+	return opt
+}
+
+// PasteboardType The type of the item on the pasteboard.
+type PasteboardType string
+
+const (
+	PasteboardTypePlaintext PasteboardType = "plaintext"
+	PasteboardTypeImage     PasteboardType = "image"
+	PasteboardTypeUrl       PasteboardType = "url"
+)
+
+const (
+	TextBackspace string = "\u0008"
+	TextDelete    string = "\u007F"
+)
+
+// type KeyboardKeyLabel string
+//
+// const (
+// 	KeyboardKeyReturn = "return"
+// )
+
+// DeviceButton A physical button on an iOS device.
+type DeviceButton string
+
+const (
+	DeviceButtonHome       DeviceButton = "home"
+	DeviceButtonVolumeUp   DeviceButton = "volumeUp"
+	DeviceButtonVolumeDown DeviceButton = "volumeDown"
+)
+
+type NotificationType string
+
+const (
+	NotificationTypePlain  NotificationType = "plain"
+	NotificationTypeDarwin NotificationType = "darwin"
+)
+
+// EventPageID The event page identifier
+type EventPageID int
+
+const EventPageIDConsumer EventPageID = 0x0C
+
+// EventUsageID The event usage identifier (usages are defined per-page)
+type EventUsageID int
+
+const (
+	EventUsageIDCsmrVolumeUp   EventUsageID = 0xE9
+	EventUsageIDCsmrVolumeDown EventUsageID = 0xEA
+	EventUsageIDCsmrHome       EventUsageID = 0x40
+	EventUsageIDCsmrPower      EventUsageID = 0x30
+	EventUsageIDCsmrSnapshot   EventUsageID = 0x65 // Power + Home
+)
+
+type Orientation string
+
+const (
+	// OrientationPortrait Device oriented vertically, home button on the bottom
+	OrientationPortrait Orientation = "PORTRAIT"
+
+	// OrientationPortraitUpsideDown Device oriented vertically, home button on the top
+	OrientationPortraitUpsideDown Orientation = "UIA_DEVICE_ORIENTATION_PORTRAIT_UPSIDEDOWN"
+
+	// OrientationLandscapeLeft Device oriented horizontally, home button on the right
+	OrientationLandscapeLeft Orientation = "LANDSCAPE"
+
+	// OrientationLandscapeRight Device oriented horizontally, home button on the left
+	OrientationLandscapeRight Orientation = "UIA_DEVICE_ORIENTATION_LANDSCAPERIGHT"
+)
+
+type Rotation struct {
+	X int `json:"x"`
+	Y int `json:"y"`
+	Z int `json:"z"`
+}
+
+// SourceOption Configure the format or attribute of the Source
+type SourceOption map[string]interface{}
+
+func NewSourceOption() SourceOption {
+	return make(SourceOption)
+}
+
+// WithFormatAsJson Application elements tree in form of json string
+func (opt SourceOption) WithFormatAsJson() SourceOption {
+	opt["format"] = "json"
+	return opt
+}
+
+// WithFormatAsXml Application elements tree in form of xml string
+func (opt SourceOption) WithFormatAsXml() SourceOption {
+	opt["format"] = "xml"
+	return opt
+}
+
+// WithFormatAsDescription Application elements tree in form of internal XCTest debugDescription string
+func (opt SourceOption) WithFormatAsDescription() SourceOption {
+	opt["format"] = "description"
+	return opt
+}
+
+// WithScope Allows to provide XML scope.
+//  only `xml` is supported.
+func (opt SourceOption) WithScope(scope string) SourceOption {
+	if vFormat, ok := opt["format"]; ok && vFormat != "xml" {
+		return opt
+	}
+	opt["scope"] = scope
+	return opt
+}
+
+// WithExcludedAttributes Excludes the given attribute names.
+// only `xml` is supported.
+func (opt SourceOption) WithExcludedAttributes(attributes []string) SourceOption {
+	if vFormat, ok := opt["format"]; ok && vFormat != "xml" {
+		return opt
+	}
+	opt["excluded_attributes"] = strings.Join(attributes, ",")
+	return opt
+}
+
+const (
+	// legacyWebElementIdentifier is the string constant used in the old
+	// WebDriver JSON protocol that is the key for the map that contains an
+	// unique element identifier.
+	legacyWebElementIdentifier = "ELEMENT"
+
+	// webElementIdentifier is the string constant defined by the W3C
+	// specification that is the key for the map that contains a unique element identifier.
+	webElementIdentifier = "element-6066-11e4-a52e-4f735466cecf"
+)
+
+func elementIDFromValue(val map[string]string) string {
+	for _, key := range []string{webElementIdentifier, legacyWebElementIdentifier} {
+		if v, ok := val[key]; ok && v != "" {
+			return v
+		}
+	}
+	return ""
+}
+
+// performance ranking: class name > accessibility id > link text > predicate > class chain > xpath
+type BySelector struct {
+	ClassName ElementType `json:"class name"`
+
+	// isSearchByIdentifier
+	Name            string `json:"name"`
+	Id              string `json:"id"`
+	AccessibilityId string `json:"accessibility id"`
+
+	// partialSearch
+	LinkText        ElementAttribute `json:"link text"`
+	PartialLinkText ElementAttribute `json:"partial link text"`
+	// partialSearch
+
+	Predicate string `json:"predicate string"`
+
+	ClassChain string `json:"class chain"`
+
+	XPath string `json:"xpath"` // not recommended, it's slow because it is not supported by XCTest natively
+
+	// Set the search criteria to match the given resource ResourceIdID.
+	ResourceIdID string `json:"id"`
+	// Set the search criteria to match the content-description property for a widget.
+	ContentDescription string `json:"accessibility id"`
+
+	UiAutomator string `json:"-android uiautomator"`
+}
+
+func (wl BySelector) getUsingAndValue() (using, value string) {
+	vBy := reflect.ValueOf(wl)
+	tBy := reflect.TypeOf(wl)
+	for i := 0; i < vBy.NumField(); i++ {
+		vi := vBy.Field(i).Interface()
+		switch vi := vi.(type) {
+		case ElementType:
+			value = vi.String()
+		case string:
+			value = vi
+		case ElementAttribute:
+			value = vi.String()
+		}
+		if value != "" && value != "UNKNOWN" {
+			using = tBy.Field(i).Tag.Get("json")
+			return
+		}
+	}
+	return
+}
+
+func (by BySelector) getMethodAndSelector() (method, selector string) {
+	vBy := reflect.ValueOf(by)
+	tBy := reflect.TypeOf(by)
+	for i := 0; i < vBy.NumField(); i++ {
+		vi := vBy.Field(i).Interface()
+		// switch vi := vi.(type) {
+		// case string:
+		// 	selector = vi
+		// }
+		selector = vi.(string)
+		if selector != "" && selector != "UNKNOWN" {
+			method = tBy.Field(i).Tag.Get("json")
+			return
+		}
+	}
+	return
+}
+
+type ElementAttribute map[string]interface{}
+
+func (ea ElementAttribute) String() string {
+	for k, v := range ea {
+		switch v := v.(type) {
+		case bool:
+			return k + "=" + strconv.FormatBool(v)
+		case string:
+			return k + "=" + v
+		default:
+			return k + "=" + fmt.Sprintf("%v", v)
+		}
+	}
+	return "UNKNOWN"
+}
+
+func (ea ElementAttribute) getAttributeName() string {
+	for k := range ea {
+		return k
+	}
+	return "UNKNOWN"
+}
+
+func NewElementAttribute() ElementAttribute {
+	return make(ElementAttribute)
+}
+
+// WithUID Element's unique identifier
+func (ea ElementAttribute) WithUID(uid string) ElementAttribute {
+	ea["UID"] = uid
+	return ea
+}
+
+// WithAccessibilityContainer Whether element is an accessibility container
+// (contains children of any depth that are accessible)
+func (ea ElementAttribute) WithAccessibilityContainer(b bool) ElementAttribute {
+	ea["accessibilityContainer"] = b
+	return ea
+}
+
+// WithAccessible Whether element is accessible
+func (ea ElementAttribute) WithAccessible(b bool) ElementAttribute {
+	ea["accessible"] = b
+	return ea
+}
+
+// WithEnabled Whether element is enabled
+func (ea ElementAttribute) WithEnabled(b bool) ElementAttribute {
+	ea["enabled"] = b
+	return ea
+}
+
+// WithLabel Element's label
+func (ea ElementAttribute) WithLabel(s string) ElementAttribute {
+	ea["label"] = s
+	return ea
+}
+
+// WithName Element's name
+func (ea ElementAttribute) WithName(s string) ElementAttribute {
+	ea["name"] = s
+	return ea
+}
+
+// WithSelected Element's selected state
+func (ea ElementAttribute) WithSelected(b bool) ElementAttribute {
+	ea["selected"] = b
+	return ea
+}
+
+// WithType Element's type
+func (ea ElementAttribute) WithType(elemType ElementType) ElementAttribute {
+	ea["type"] = elemType
+	return ea
+}
+
+// WithValue Element's value
+func (ea ElementAttribute) WithValue(s string) ElementAttribute {
+	ea["value"] = s
+	return ea
+}
+
+// WithVisible
+//
+// Whether element is visible
+func (ea ElementAttribute) WithVisible(b bool) ElementAttribute {
+	ea["visible"] = b
+	return ea
+}
+
+func (et ElementType) String() string {
+	vBy := reflect.ValueOf(et)
+	tBy := reflect.TypeOf(et)
+	for i := 0; i < vBy.NumField(); i++ {
+		if vBy.Field(i).Bool() {
+			return tBy.Field(i).Tag.Get("json")
+		}
+	}
+	return "UNKNOWN"
+}
+
+// ElementType
+// !!! This mapping should be updated if there are changes after each new XCTest release"`
+type ElementType struct {
+	Any                bool `json:"XCUIElementTypeAny"`
+	Other              bool `json:"XCUIElementTypeOther"`
+	Application        bool `json:"XCUIElementTypeApplication"`
+	Group              bool `json:"XCUIElementTypeGroup"`
+	Window             bool `json:"XCUIElementTypeWindow"`
+	Sheet              bool `json:"XCUIElementTypeSheet"`
+	Drawer             bool `json:"XCUIElementTypeDrawer"`
+	Alert              bool `json:"XCUIElementTypeAlert"`
+	Dialog             bool `json:"XCUIElementTypeDialog"`
+	Button             bool `json:"XCUIElementTypeButton"`
+	RadioButton        bool `json:"XCUIElementTypeRadioButton"`
+	RadioGroup         bool `json:"XCUIElementTypeRadioGroup"`
+	CheckBox           bool `json:"XCUIElementTypeCheckBox"`
+	DisclosureTriangle bool `json:"XCUIElementTypeDisclosureTriangle"`
+	PopUpButton        bool `json:"XCUIElementTypePopUpButton"`
+	ComboBox           bool `json:"XCUIElementTypeComboBox"`
+	MenuButton         bool `json:"XCUIElementTypeMenuButton"`
+	ToolbarButton      bool `json:"XCUIElementTypeToolbarButton"`
+	Popover            bool `json:"XCUIElementTypePopover"`
+	Keyboard           bool `json:"XCUIElementTypeKeyboard"`
+	Key                bool `json:"XCUIElementTypeKey"`
+	NavigationBar      bool `json:"XCUIElementTypeNavigationBar"`
+	TabBar             bool `json:"XCUIElementTypeTabBar"`
+	TabGroup           bool `json:"XCUIElementTypeTabGroup"`
+	Toolbar            bool `json:"XCUIElementTypeToolbar"`
+	StatusBar          bool `json:"XCUIElementTypeStatusBar"`
+	Table              bool `json:"XCUIElementTypeTable"`
+	TableRow           bool `json:"XCUIElementTypeTableRow"`
+	TableColumn        bool `json:"XCUIElementTypeTableColumn"`
+	Outline            bool `json:"XCUIElementTypeOutline"`
+	OutlineRow         bool `json:"XCUIElementTypeOutlineRow"`
+	Browser            bool `json:"XCUIElementTypeBrowser"`
+	CollectionView     bool `json:"XCUIElementTypeCollectionView"`
+	Slider             bool `json:"XCUIElementTypeSlider"`
+	PageIndicator      bool `json:"XCUIElementTypePageIndicator"`
+	ProgressIndicator  bool `json:"XCUIElementTypeProgressIndicator"`
+	ActivityIndicator  bool `json:"XCUIElementTypeActivityIndicator"`
+	SegmentedControl   bool `json:"XCUIElementTypeSegmentedControl"`
+	Picker             bool `json:"XCUIElementTypePicker"`
+	PickerWheel        bool `json:"XCUIElementTypePickerWheel"`
+	Switch             bool `json:"XCUIElementTypeSwitch"`
+	Toggle             bool `json:"XCUIElementTypeToggle"`
+	Link               bool `json:"XCUIElementTypeLink"`
+	Image              bool `json:"XCUIElementTypeImage"`
+	Icon               bool `json:"XCUIElementTypeIcon"`
+	SearchField        bool `json:"XCUIElementTypeSearchField"`
+	ScrollView         bool `json:"XCUIElementTypeScrollView"`
+	ScrollBar          bool `json:"XCUIElementTypeScrollBar"`
+	StaticText         bool `json:"XCUIElementTypeStaticText"`
+	TextField          bool `json:"XCUIElementTypeTextField"`
+	SecureTextField    bool `json:"XCUIElementTypeSecureTextField"`
+	DatePicker         bool `json:"XCUIElementTypeDatePicker"`
+	TextView           bool `json:"XCUIElementTypeTextView"`
+	Menu               bool `json:"XCUIElementTypeMenu"`
+	MenuItem           bool `json:"XCUIElementTypeMenuItem"`
+	MenuBar            bool `json:"XCUIElementTypeMenuBar"`
+	MenuBarItem        bool `json:"XCUIElementTypeMenuBarItem"`
+	Map                bool `json:"XCUIElementTypeMap"`
+	WebView            bool `json:"XCUIElementTypeWebView"`
+	IncrementArrow     bool `json:"XCUIElementTypeIncrementArrow"`
+	DecrementArrow     bool `json:"XCUIElementTypeDecrementArrow"`
+	Timeline           bool `json:"XCUIElementTypeTimeline"`
+	RatingIndicator    bool `json:"XCUIElementTypeRatingIndicator"`
+	ValueIndicator     bool `json:"XCUIElementTypeValueIndicator"`
+	SplitGroup         bool `json:"XCUIElementTypeSplitGroup"`
+	Splitter           bool `json:"XCUIElementTypeSplitter"`
+	RelevanceIndicator bool `json:"XCUIElementTypeRelevanceIndicator"`
+	ColorWell          bool `json:"XCUIElementTypeColorWell"`
+	HelpTag            bool `json:"XCUIElementTypeHelpTag"`
+	Matte              bool `json:"XCUIElementTypeMatte"`
+	DockItem           bool `json:"XCUIElementTypeDockItem"`
+	Ruler              bool `json:"XCUIElementTypeRuler"`
+	RulerMarker        bool `json:"XCUIElementTypeRulerMarker"`
+	Grid               bool `json:"XCUIElementTypeGrid"`
+	LevelIndicator     bool `json:"XCUIElementTypeLevelIndicator"`
+	Cell               bool `json:"XCUIElementTypeCell"`
+	LayoutArea         bool `json:"XCUIElementTypeLayoutArea"`
+	LayoutItem         bool `json:"XCUIElementTypeLayoutItem"`
+	Handle             bool `json:"XCUIElementTypeHandle"`
+	Stepper            bool `json:"XCUIElementTypeStepper"`
+	Tab                bool `json:"XCUIElementTypeTab"`
+	TouchBar           bool `json:"XCUIElementTypeTouchBar"`
+	StatusItem         bool `json:"XCUIElementTypeStatusItem"`
+	EditText           bool `json:"android.widget.EditText"`
+}
+
+// ProtectedResource A system resource that requires user authorization to access.
+type ProtectedResource int
+
+// https://developer.apple.com/documentation/xctest/xcuiprotectedresource?language=objc
+const (
+	ProtectedResourceContacts               ProtectedResource = 1
+	ProtectedResourceCalendar               ProtectedResource = 2
+	ProtectedResourceReminders              ProtectedResource = 3
+	ProtectedResourcePhotos                 ProtectedResource = 4
+	ProtectedResourceMicrophone             ProtectedResource = 5
+	ProtectedResourceCamera                 ProtectedResource = 6
+	ProtectedResourceMediaLibrary           ProtectedResource = 7
+	ProtectedResourceHomeKit                ProtectedResource = 8
+	ProtectedResourceSystemRootDirectory    ProtectedResource = 0x40000000
+	ProtectedResourceUserDesktopDirectory   ProtectedResource = 0x40000001
+	ProtectedResourceUserDownloadsDirectory ProtectedResource = 0x40000002
+	ProtectedResourceUserDocumentsDirectory ProtectedResource = 0x40000003
+	ProtectedResourceBluetooth              ProtectedResource = -0x40000000
+	ProtectedResourceKeyboardNetwork        ProtectedResource = -0x40000001
+	ProtectedResourceLocation               ProtectedResource = -0x40000002
+	ProtectedResourceHealth                 ProtectedResource = -0x40000003
+)
+
+type Condition func(wd WebDriver) (bool, error)
+
+type Direction string
+
+const (
+	DirectionUp    Direction = "up"
+	DirectionDown  Direction = "down"
+	DirectionLeft  Direction = "left"
+	DirectionRight Direction = "right"
+)
+
+type PickerWheelOrder string
+
+const (
+	PickerWheelOrderNext     PickerWheelOrder = "next"
+	PickerWheelOrderPrevious PickerWheelOrder = "previous"
+)
+
+type Point struct {
+	X int `json:"x"` // upper left X coordinate of selected element
+	Y int `json:"y"` // upper left Y coordinate of selected element
+}
+
+type PointF struct {
+	X float64 `json:"x"`
+	Y float64 `json:"y"`
+}
+
+type Rect struct {
+	Point
+	Size
+}
+
+type DataOptions struct {
+	Data                map[string]interface{} // configurations used by ios/android driver
+	Scope               []int                  // used by ocr to get text position in the scope
+	Index               int                    // index of the target element, should start from 1
+	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
+}
+
+type DataOption func(data *DataOptions)
+
+func WithCustomOption(key string, value interface{}) DataOption {
+	return func(data *DataOptions) {
+		data.Data[key] = value
+	}
+}
+
+func WithDataPressDuration(duration float64) DataOption {
+	return func(data *DataOptions) {
+		data.Data["duration"] = duration
+	}
+}
+
+func WithDataSteps(steps int) DataOption {
+	return func(data *DataOptions) {
+		data.Data["steps"] = steps
+	}
+}
+
+func WithDataFrequency(frequency int) DataOption {
+	return func(data *DataOptions) {
+		data.Data["frequency"] = frequency
+	}
+}
+
+func WithDataIndex(index int) DataOption {
+	return func(data *DataOptions) {
+		data.Index = index
+	}
+}
+
+func WithDataScope(x1, x2, y1, y2 int) DataOption {
+	return func(data *DataOptions) {
+		data.Scope = []int{x1, x2, y1, y2}
+	}
+}
+
+func WithDataIdentifier(identifier string) DataOption {
+	if identifier == "" {
+		return func(data *DataOptions) {}
+	}
+	return func(data *DataOptions) {
+		data.Data["log"] = map[string]interface{}{
+			"enable": true,
+			"data":   identifier,
+		}
+	}
+}
+
+func WithDataIgnoreNotFoundError(ignoreError bool) DataOption {
+	return func(data *DataOptions) {
+		data.IgnoreNotFoundError = ignoreError
+	}
+}
+
+func WithDataMaxRetryTimes(maxRetryTimes int) DataOption {
+	return func(data *DataOptions) {
+		data.MaxRetryTimes = maxRetryTimes
+	}
+}
+
+func WithDataWaitTime(sec float64) DataOption {
+	return func(data *DataOptions) {
+		data.Interval = sec
+	}
+}
+
+func NewData(data map[string]interface{}, options ...DataOption) *DataOptions {
+	if data == nil {
+		data = make(map[string]interface{})
+	}
+	dataOptions := &DataOptions{
+		Data: data,
+	}
+	for _, option := range options {
+		option(dataOptions)
+	}
+
+	if len(dataOptions.Scope) == 0 {
+		dataOptions.Scope = []int{0, 0, math.MaxInt64, math.MaxInt64} // default scope
+	}
+
+	if _, ok := dataOptions.Data["steps"]; !ok {
+		dataOptions.Data["steps"] = 12 // default steps
+	}
+
+	if _, ok := dataOptions.Data["duration"]; !ok {
+		dataOptions.Data["duration"] = 1.0 // default duration
+	}
+
+	if _, ok := dataOptions.Data["frequency"]; !ok {
+		dataOptions.Data["frequency"] = 60 // default frequency
+	}
+
+	if _, ok := dataOptions.Data["isReplace"]; !ok {
+		dataOptions.Data["isReplace"] = true // default true
+	}
+
+	return dataOptions
+}
+
+// current implemeted device: IOSDevice, AndroidDevice
+type Device interface {
+	UUID() string
+	NewDriver(capabilities Capabilities) (driverExt *DriverExt, err error)
+}
+
+// WebDriver defines methods supported by WebDriver drivers.
+type WebDriver interface {
+	// NewSession starts a new session and returns the SessionInfo.
+	NewSession(capabilities Capabilities) (SessionInfo, error)
+
+	ActiveSession() (SessionInfo, error)
+	// DeleteSession Kills application associated with that session and removes session
+	//  1) alertsMonitor disable
+	//  2) testedApplicationBundleId terminate
+	DeleteSession() error
+
+	Status() (DeviceStatus, error)
+
+	DeviceInfo() (DeviceInfo, error)
+
+	// Location Returns device location data.
+	//
+	// It requires to configure location access permission by manual.
+	// The response of 'latitude', 'longitude' and 'altitude' are always zero (0) without authorization.
+	// 'authorizationStatus' indicates current authorization status. '3' is 'Always'.
+	// https://developer.apple.com/documentation/corelocation/clauthorizationstatus
+	//
+	//  Settings -> Privacy -> Location Service -> WebDriverAgent-Runner -> Always
+	//
+	// The return value could be zero even if the permission is set to 'Always'
+	// since the location service needs some time to update the location data.
+	Location() (Location, error)
+	BatteryInfo() (BatteryInfo, error)
+	WindowSize() (Size, error)
+	Screen() (Screen, error)
+	Scale() (float64, error)
+	ActiveAppInfo() (AppInfo, error)
+	// ActiveAppsList Retrieves the information about the currently active apps
+	ActiveAppsList() ([]AppBaseInfo, error)
+	// AppState Get the state of the particular application in scope of the current session.
+	// !This method is only returning reliable results since Xcode9 SDK
+	AppState(bundleId string) (AppState, error)
+
+	// IsLocked Checks if the screen is locked or not.
+	IsLocked() (bool, error)
+	// Unlock Forces the device under test to unlock.
+	// An immediate return will happen if the device is already unlocked
+	// and an error is going to be thrown if the screen has not been unlocked after the timeout.
+	Unlock() error
+	// Lock Forces the device under test to switch to the lock screen.
+	// An immediate return will happen if the device is already locked
+	// and an error is going to be thrown if the screen has not been locked after the timeout.
+	Lock() error
+
+	// Homescreen Forces the device under test to switch to the home screen
+	Homescreen() error
+
+	// AlertText Returns alert's title and description separated by new lines
+	AlertText() (string, error)
+	// AlertButtons Gets the labels of the buttons visible in the alert
+	AlertButtons() ([]string, error)
+	// AlertAccept Accepts alert, if present
+	AlertAccept(label ...string) error
+	// AlertDismiss Dismisses alert, if present
+	AlertDismiss(label ...string) error
+	// AlertSendKeys Types a text into an input inside the alert container, if it is present
+	AlertSendKeys(text string) error
+
+	// AppLaunch Launch an application with given bundle identifier in scope of current session.
+	// !This method is only available since Xcode9 SDK
+	AppLaunch(bundleId string, launchOpt ...AppLaunchOption) error
+	// AppLaunchUnattached Launch the app with the specified bundle ID.
+	AppLaunchUnattached(bundleId string) error
+	// AppTerminate Terminate an application with the given bundle id.
+	// Either `true` if the app has been successfully terminated or `false` if it was not running
+	AppTerminate(bundleId string) (bool, error)
+	// AppActivate Activate an application with given bundle identifier in scope of current session.
+	// !This method is only available since Xcode9 SDK
+	AppActivate(bundleId string) error
+	// AppDeactivate Deactivates application for given time and then activate it again
+	//  The minimum application switch wait is 3 seconds
+	AppDeactivate(second float64) error
+
+	// AppAuthReset Resets the authorization status for a protected resource. Available since Xcode 11.4
+	AppAuthReset(ProtectedResource) error
+
+	// StartCamera Starts a new camera for recording
+	StartCamera() error
+	// StopCamera Stops the camera for recording
+	StopCamera() error
+
+	// Tap Sends a tap event at the coordinate.
+	Tap(x, y int, options ...DataOption) error
+	TapFloat(x, y float64, options ...DataOption) error
+
+	// DoubleTap Sends a double tap event at the coordinate.
+	DoubleTap(x, y int) error
+	DoubleTapFloat(x, y float64) error
+
+	// TouchAndHold Initiates a long-press gesture at the coordinate, holding for the specified duration.
+	//  second: The default value is 1
+	TouchAndHold(x, y int, second ...float64) error
+	TouchAndHoldFloat(x, y float64, second ...float64) error
+
+	// Drag Initiates a press-and-hold gesture at the coordinate, then drags to another coordinate.
+	// WithPressDurationOption option can be used to set pressForDuration (default to 1 second).
+	Drag(fromX, fromY, toX, toY int, options ...DataOption) error
+	DragFloat(fromX, fromY, toX, toY float64, options ...DataOption) error
+
+	// Swipe works like Drag, but `pressForDuration` value is 0
+	Swipe(fromX, fromY, toX, toY int, options ...DataOption) error
+	SwipeFloat(fromX, fromY, toX, toY float64, options ...DataOption) error
+
+	ForceTouch(x, y int, pressure float64, second ...float64) error
+	ForceTouchFloat(x, y, pressure float64, second ...float64) error
+
+	// PerformW3CActions Perform complex touch action in scope of the current application.
+	PerformW3CActions(actions *W3CActions) error
+	PerformAppiumTouchActions(touchActs *TouchActions) error
+
+	// SetPasteboard Sets data to the general pasteboard
+	SetPasteboard(contentType PasteboardType, content string) error
+	// GetPasteboard Gets the data contained in the general pasteboard.
+	//  It worked when `WDA` was foreground. https://github.com/appium/WebDriverAgent/issues/330
+	GetPasteboard(contentType PasteboardType) (raw *bytes.Buffer, err error)
+
+	// SendKeys Types a string into active element. There must be element with keyboard focus,
+	// otherwise an error is raised.
+	// WithFrequency option can be used to set frequency of typing (letters per sec). The default value is 60
+	SendKeys(text string, options ...DataOption) error
+
+	// Input works like SendKeys
+	Input(text string, options ...DataOption) error
+
+	// KeyboardDismiss Tries to dismiss the on-screen keyboard
+	KeyboardDismiss(keyNames ...string) error
+
+	// PressButton Presses the corresponding hardware button on the device
+	PressButton(devBtn DeviceButton) error
+
+	// IOHIDEvent Emulated triggering of the given low-level IOHID device event.
+	//  duration: The event duration in float seconds (XCTest uses 0.005 for a single press event)
+	IOHIDEvent(pageID EventPageID, usageID EventUsageID, duration ...float64) error
+
+	// ExpectNotification Creates an expectation that is fulfilled when an expected Notification is received
+	ExpectNotification(notifyName string, notifyType NotificationType, second ...int) error
+
+	// SiriActivate Activates Siri service voice recognition with the given text to parse
+	SiriActivate(text string) error
+	// SiriOpenUrl Opens the particular url scheme using Siri voice recognition helpers.
+	// !This will only work since XCode 8.3/iOS 10.3
+	//  It doesn't actually work, right?
+	SiriOpenUrl(url string) error
+
+	Orientation() (Orientation, error)
+	// SetOrientation Sets requested device interface orientation.
+	SetOrientation(Orientation) error
+
+	Rotation() (Rotation, error)
+	// SetRotation Sets the devices orientation to the rotation passed.
+	SetRotation(Rotation) error
+
+	// MatchTouchID Matches or mismatches TouchID request
+	MatchTouchID(isMatch bool) error
+
+	// ActiveElement Returns the element, which currently holds the keyboard input focus or nil if there are no such elements.
+	ActiveElement() (WebElement, error)
+	FindElement(by BySelector) (WebElement, error)
+	FindElements(by BySelector) ([]WebElement, error)
+
+	Screenshot() (*bytes.Buffer, error)
+
+	// Source Return application elements tree
+	Source(srcOpt ...SourceOption) (string, error)
+	// AccessibleSource Return application elements accessibility tree
+	AccessibleSource() (string, error)
+
+	// HealthCheck Health check might modify simulator state so it should only be called in-between testing sessions
+	//  Checks health of XCTest by:
+	//  1) Querying application for some elements,
+	//  2) Triggering some device events.
+	HealthCheck() error
+	GetAppiumSettings() (map[string]interface{}, error)
+	SetAppiumSettings(settings map[string]interface{}) (map[string]interface{}, error)
+
+	IsHealthy() (bool, error)
+
+	// WaitWithTimeoutAndInterval waits for the condition to evaluate to true.
+	WaitWithTimeoutAndInterval(condition Condition, timeout, interval time.Duration) error
+	// WaitWithTimeout works like WaitWithTimeoutAndInterval, but with default polling interval.
+	WaitWithTimeout(condition Condition, timeout time.Duration) error
+	// Wait works like WaitWithTimeoutAndInterval, but using the default timeout and polling interval.
+	Wait(condition Condition) error
+
+	// Close inner connections properly
+	Close() error
+
+	// triggers the log capture and returns the log entries
+	StartCaptureLog(identifier ...string) (err error)
+	StopCaptureLog() (result interface{}, err error)
+}
+
+// WebElement defines method supported by web elements.
+type WebElement interface {
+	// Click Waits for element to become stable (not move) and performs sync tap on element.
+	Click() error
+	// SendKeys Types a text into element. It will try to activate keyboard on element,
+	// if element has no keyboard focus.
+	//  frequency: Frequency of typing (letters per sec). The default value is 60
+	SendKeys(text string, options ...DataOption) error
+	// Clear Clears text on element. It will try to activate keyboard on element,
+	// if element has no keyboard focus.
+	Clear() error
+
+	// Tap Waits for element to become stable (not move) and performs sync tap on element,
+	// relative to the current element position
+	Tap(x, y int) error
+	TapFloat(x, y float64) error
+
+	// DoubleTap Sends a double tap event to a hittable point computed for the element.
+	DoubleTap() error
+
+	// TouchAndHold Sends a long-press gesture to a hittable point computed for the element,
+	// holding for the specified duration.
+	//  second: The default value is 1
+	TouchAndHold(second ...float64) error
+	// TwoFingerTap Sends a two finger tap event to a hittable point computed for the element.
+	TwoFingerTap() error
+	// TapWithNumberOfTaps Sends one or more taps with one or more touch points.
+	TapWithNumberOfTaps(numberOfTaps, numberOfTouches int) error
+	// ForceTouch Waits for element to become stable (not move) and performs sync force touch on element.
+	//  second: The default value is 1
+	ForceTouch(pressure float64, second ...float64) error
+	// ForceTouchFloat works like ForceTouch, but relative to the current element position
+	ForceTouchFloat(x, y, pressure float64, second ...float64) error
+
+	// Drag Initiates a press-and-hold gesture at the coordinate, then drags to another coordinate.
+	// relative to the current element position
+	//  pressForDuration: The default value is 1 second.
+	Drag(fromX, fromY, toX, toY int, pressForDuration ...float64) error
+	DragFloat(fromX, fromY, toX, toY float64, pressForDuration ...float64) error
+
+	// Swipe works like Drag, but `pressForDuration` value is 0.
+	// relative to the current element position
+	Swipe(fromX, fromY, toX, toY int) error
+	SwipeFloat(fromX, fromY, toX, toY float64) error
+	// SwipeDirection Performs swipe gesture on the element.
+	//  velocity: swipe speed in pixels per second. Custom velocity values are only supported since Xcode SDK 11.4.
+	SwipeDirection(direction Direction, velocity ...float64) error
+
+	// Pinch Sends a pinching gesture with two touches.
+	//  scale: The scale of the pinch gesture. Use a scale between 0 and 1 to "pinch close" or zoom out
+	//  and a scale greater than 1 to "pinch open" or zoom in.
+	//  velocity: The velocity of the pinch in scale factor per second.
+	Pinch(scale, velocity float64) error
+	PinchToZoomOutByW3CAction(scale ...float64) error
+
+	// Rotate Sends a rotation gesture with two touches.
+	//  rotation: The rotation of the gesture in radians.
+	//  velocity: The velocity of the rotation gesture in radians per second.
+	Rotate(rotation float64, velocity ...float64) error
+
+	// PickerWheelSelect
+	//  offset: The default value is 2
+	PickerWheelSelect(order PickerWheelOrder, offset ...int) error
+
+	ScrollElementByName(name string) error
+	ScrollElementByPredicate(predicate string) error
+	ScrollToVisible() error
+	// ScrollDirection
+	//  distance: The default value is 0.5
+	ScrollDirection(direction Direction, distance ...float64) error
+
+	FindElement(by BySelector) (element WebElement, err error)
+	FindElements(by BySelector) (elements []WebElement, err error)
+	FindVisibleCells() (elements []WebElement, err error)
+
+	Rect() (rect Rect, err error)
+	Location() (Point, error)
+	Size() (Size, error)
+	Text() (text string, err error)
+	Type() (elemType string, err error)
+	IsEnabled() (enabled bool, err error)
+	IsDisplayed() (displayed bool, err error)
+	IsSelected() (selected bool, err error)
+	IsAccessible() (accessible bool, err error)
+	IsAccessibilityContainer() (isAccessibilityContainer bool, err error)
+	GetAttribute(attr ElementAttribute) (value string, err error)
+	UID() (uid string)
+
+	Screenshot() (raw *bytes.Buffer, err error)
+}
diff --git a/hrp/pkg/uixt/ios_action.go b/hrp/pkg/uixt/ios_action.go
new file mode 100644
index 000000000..0541f827c
--- /dev/null
+++ b/hrp/pkg/uixt/ios_action.go
@@ -0,0 +1,373 @@
+package uixt
+
+import (
+	"strconv"
+	"strings"
+)
+
+type W3CActions []map[string]interface{}
+
+func NewW3CActions(capacity ...int) *W3CActions {
+	if len(capacity) == 0 || capacity[0] <= 0 {
+		capacity = []int{8}
+	}
+	tmp := make(W3CActions, 0, capacity[0])
+	return &tmp
+}
+
+func (act *W3CActions) SendKeys(text string) *W3CActions {
+	keyboard := make(map[string]interface{})
+	keyboard["type"] = "key"
+	keyboard["id"] = "keyboard" + strconv.FormatInt(int64(len(*act)+1), 10)
+
+	ss := strings.Split(text, "")
+	type KeyEvent struct {
+		Type  string `json:"type"`
+		Value string `json:"value"`
+	}
+	actOptKey := make([]KeyEvent, 0, len(ss)+1)
+	for i := range ss {
+		actOptKey = append(
+			actOptKey,
+			KeyEvent{Type: "keyDown", Value: ss[i]},
+			KeyEvent{Type: "keyUp", Value: ss[i]},
+		)
+	}
+	keyboard["actions"] = actOptKey
+	*act = append(*act, keyboard)
+	return act
+}
+
+func (act *W3CActions) _newFinger() map[string]interface{} {
+	pointer := make(map[string]interface{})
+	pointer["type"] = "pointer"
+	pointer["id"] = "finger" + strconv.FormatInt(int64(len(*act)+1), 10)
+	pointer["parameters"] = map[string]string{"pointerType": "touch"}
+	return pointer
+}
+
+func (act *W3CActions) FingerAction(fingerAct *FingerAction, fActs ...*FingerAction) *W3CActions {
+	fActs = append([]*FingerAction{fingerAct}, fActs...)
+	for i := range fActs {
+		pointer := act._newFinger()
+		pointer["actions"] = *fActs[i]
+		*act = append(*act, pointer)
+	}
+	return act
+}
+
+type FingerAction []map[string]interface{}
+
+func NewFingerAction(capacity ...int) *FingerAction {
+	if len(capacity) == 0 || capacity[0] <= 0 {
+		capacity = []int{8}
+	}
+	tmp := make(FingerAction, 0, capacity[0])
+	return &tmp
+}
+
+type FingerMove map[string]interface{}
+
+func NewFingerMove() FingerMove {
+	return map[string]interface{}{"type": "pointerMove"}
+}
+
+func (fm FingerMove) WithXY(x, y int) FingerMove {
+	fm["x"] = x
+	fm["y"] = y
+	return fm
+}
+
+func (fm FingerMove) WithXYFloat(x, y float64) FingerMove {
+	fm["x"] = x
+	fm["y"] = y
+	return fm
+}
+
+func (fm FingerMove) WithOrigin(element WebElement) FingerMove {
+	fm["origin"] = element.UID()
+	return fm
+}
+
+func (fm FingerMove) WithDuration(second float64) FingerMove {
+	fm["duration"] = second
+	return fm
+}
+
+func (fa *FingerAction) Move(fm FingerMove) *FingerAction {
+	*fa = append(*fa, fm)
+	return fa
+}
+
+func (fa *FingerAction) Down() *FingerAction {
+	*fa = append(*fa, map[string]interface{}{"type": "pointerDown"})
+	return fa
+}
+
+func (fa *FingerAction) Up() *FingerAction {
+	*fa = append(*fa, map[string]interface{}{"type": "pointerUp"})
+	return fa
+}
+
+func (fa *FingerAction) Pause(second ...float64) *FingerAction {
+	if len(second) == 0 || second[0] < 0 {
+		second = []float64{0.5}
+	}
+	tmp := map[string]interface{}{
+		"type":     "pause",
+		"duration": second[0] * 1000,
+	}
+	*fa = append(*fa, tmp)
+	return fa
+}
+
+func (act *W3CActions) Tap(x, y int, element ...WebElement) *W3CActions {
+	fm := NewFingerMove().WithXY(x, y)
+	if len(element) != 0 {
+		fm.WithOrigin(element[0])
+	}
+	fingerAction := NewFingerAction().
+		Move(fm).
+		Down().
+		Pause(0.1).
+		Up()
+	return act.FingerAction(fingerAction)
+}
+
+func (act *W3CActions) DoubleTap(x, y int, element ...WebElement) *W3CActions {
+	fm := NewFingerMove().WithXY(x, y)
+	if len(element) != 0 {
+		fm.WithOrigin(element[0])
+	}
+	fingerAction := NewFingerAction().
+		Move(fm).
+		Down().
+		Pause(0.1).
+		Up().
+		Pause(0.04).
+		Down().
+		Pause(0.1).
+		Up()
+	return act.FingerAction(fingerAction)
+}
+
+func (act *W3CActions) Press(x, y int, second float64, element ...WebElement) *W3CActions {
+	fm := NewFingerMove().WithXY(x, y)
+	if len(element) != 0 {
+		fm.WithOrigin(element[0])
+	}
+	fingerAction := NewFingerAction().
+		Move(fm).
+		Down().
+		Pause(second).
+		Up()
+	return act.FingerAction(fingerAction)
+}
+
+func (act *W3CActions) Swipe(fromX, fromY, toX, toY int, element ...WebElement) *W3CActions {
+	fmFrom := NewFingerMove().WithXY(fromX, fromY)
+	fmTo := NewFingerMove().WithXY(toX, toY)
+	if len(element) != 0 {
+		fmFrom.WithOrigin(element[0])
+		fmTo.WithOrigin(element[0])
+	}
+	fingerAction := NewFingerAction().
+		Move(fmFrom).
+		Down().
+		Pause(0.25).
+		Move(fmTo).
+		Pause(0.25).
+		Up()
+	return act.FingerAction(fingerAction)
+}
+
+func (act *W3CActions) SwipeFloat(fromX, fromY, toX, toY float64, element ...WebElement) *W3CActions {
+	fmFrom := NewFingerMove().WithXYFloat(fromX, fromY)
+	fmTo := NewFingerMove().WithXYFloat(toX, toY)
+	if len(element) != 0 {
+		fmFrom.WithOrigin(element[0])
+		fmTo.WithOrigin(element[0])
+	}
+	fingerAction := NewFingerAction().
+		Move(fmFrom).
+		Down().
+		Pause(0.25).
+		Move(fmTo).
+		Pause(0.25).
+		Up()
+	return act.FingerAction(fingerAction)
+}
+
+/* ---------------------------------------------------------------------------------------------------------------- */
+
+type TouchActions []map[string]interface{}
+
+func NewTouchActions(capacity ...int) *TouchActions {
+	if len(capacity) == 0 || capacity[0] <= 0 {
+		capacity = []int{8}
+	}
+	tmp := make(TouchActions, 0, capacity[0])
+	return &tmp
+}
+
+func (act *TouchActions) MoveTo(opt TouchActionMoveTo) *TouchActions {
+	tmp := map[string]interface{}{
+		"action":  "moveTo",
+		"options": opt,
+	}
+	*act = append(*act, tmp)
+	return act
+}
+
+func (act *TouchActions) Tap(opt TouchActionTap) *TouchActions {
+	tmp := map[string]interface{}{
+		"action":  "tap",
+		"options": opt,
+	}
+	*act = append(*act, tmp)
+	return act
+}
+
+func (act *TouchActions) Press(opt TouchActionPress) *TouchActions {
+	tmp := map[string]interface{}{
+		"action":  "press",
+		"options": opt,
+	}
+	*act = append(*act, tmp)
+	return act
+}
+
+func (act *TouchActions) LongPress(opt TouchActionLongPress) *TouchActions {
+	tmp := map[string]interface{}{
+		"action":  "longPress",
+		"options": opt,
+	}
+	*act = append(*act, tmp)
+	return act
+}
+
+func (act *TouchActions) Wait(second ...float64) *TouchActions {
+	if len(second) == 0 || second[0] < 0 {
+		second = []float64{0.5}
+	}
+	tmp := map[string]interface{}{
+		"action":  "wait",
+		"options": map[string]interface{}{"ms": second[0] * 1000},
+	}
+	*act = append(*act, tmp)
+	return act
+}
+
+func (act *TouchActions) Release() *TouchActions {
+	tmp := map[string]interface{}{"action": "release"}
+	*act = append(*act, tmp)
+	return act
+}
+
+func (act *TouchActions) Cancel() *TouchActions {
+	tmp := map[string]interface{}{"action": "cancel"}
+	*act = append(*act, tmp)
+	return act
+}
+
+type TouchActionMoveTo map[string]interface{}
+
+func NewTouchActionMoveTo() TouchActionMoveTo {
+	return make(map[string]interface{})
+}
+
+func (opt TouchActionMoveTo) WithXY(x, y int) TouchActionMoveTo {
+	opt["x"] = x
+	opt["y"] = y
+	return opt
+}
+
+func (opt TouchActionMoveTo) WithXYFloat(x, y float64) TouchActionMoveTo {
+	opt["x"] = x
+	opt["y"] = y
+	return opt
+}
+
+func (opt TouchActionMoveTo) WithElement(element WebElement) TouchActionMoveTo {
+	opt["element"] = element.UID()
+	return opt
+}
+
+type TouchActionTap map[string]interface{}
+
+func NewTouchActionTap() TouchActionTap {
+	return make(map[string]interface{})
+}
+
+func (opt TouchActionTap) WithXY(x, y int) TouchActionTap {
+	opt["x"] = x
+	opt["y"] = y
+	return opt
+}
+
+func (opt TouchActionTap) WithXYFloat(x, y float64) TouchActionTap {
+	opt["x"] = x
+	opt["y"] = y
+	return opt
+}
+
+func (opt TouchActionTap) WithElement(element WebElement) TouchActionTap {
+	opt["element"] = element.UID()
+	return opt
+}
+
+func (opt TouchActionTap) WithCount(count int) TouchActionTap {
+	opt["count"] = count
+	return opt
+}
+
+type TouchActionPress map[string]interface{}
+
+func NewTouchActionPress() TouchActionPress {
+	return make(map[string]interface{})
+}
+
+func (opt TouchActionPress) WithXY(x, y int) TouchActionPress {
+	opt["x"] = x
+	opt["y"] = y
+	return opt
+}
+
+func (opt TouchActionPress) WithXYFloat(x, y float64) TouchActionPress {
+	opt["x"] = x
+	opt["y"] = y
+	return opt
+}
+
+func (opt TouchActionPress) WithElement(element WebElement) TouchActionPress {
+	opt["element"] = element.UID()
+	return opt
+}
+
+func (opt TouchActionPress) WithPressure(pressure float64) TouchActionPress {
+	opt["pressure"] = pressure
+	return opt
+}
+
+type TouchActionLongPress map[string]interface{}
+
+func NewTouchActionLongPress() TouchActionLongPress {
+	return make(map[string]interface{})
+}
+
+func (opt TouchActionLongPress) WithXY(x, y int) TouchActionLongPress {
+	opt["x"] = x
+	opt["y"] = y
+	return opt
+}
+
+func (opt TouchActionLongPress) WithXYFloat(x, y float64) TouchActionLongPress {
+	opt["x"] = x
+	opt["y"] = y
+	return opt
+}
+
+func (opt TouchActionLongPress) WithElement(element WebElement) TouchActionLongPress {
+	opt["element"] = element.UID()
+	return opt
+}
diff --git a/hrp/pkg/uixt/ios_device.go b/hrp/pkg/uixt/ios_device.go
new file mode 100644
index 000000000..6b2cadc16
--- /dev/null
+++ b/hrp/pkg/uixt/ios_device.go
@@ -0,0 +1,625 @@
+package uixt
+
+import (
+	"bytes"
+	"encoding/base64"
+	builtinJSON "encoding/json"
+	"fmt"
+	"io"
+	"mime"
+	"mime/multipart"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"regexp"
+	"strings"
+	"time"
+
+	giDevice "github.com/electricbubble/gidevice"
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
+	"github.com/httprunner/httprunner/v4/hrp/internal/env"
+	"github.com/httprunner/httprunner/v4/hrp/internal/json"
+)
+
+const (
+	defaultWDAPort   = 8100
+	defaultMjpegPort = 9100
+)
+
+const (
+	// Changes the value of maximum depth for traversing elements source tree.
+	// It may help to prevent out of memory or timeout errors while getting the elements source tree,
+	// but it might restrict the depth of source tree.
+	// A part of elements source tree might be lost if the value was too small. Defaults to 50
+	snapshotMaxDepth = 10
+	// Allows to customize accept/dismiss alert button selector.
+	// It helps you to handle an arbitrary element as accept button in accept alert command.
+	// The selector should be a valid class chain expression, where the search root is the alert element itself.
+	// The default button location algorithm is used if the provided selector is wrong or does not match any element.
+	// e.g. **/XCUIElementTypeButton[`label CONTAINS[c] ‘accept’`]
+	acceptAlertButtonSelector  = "**/XCUIElementTypeButton[`label IN {'允许','好','仅在使用应用期间','稍后再说'}`]"
+	dismissAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'不允许','暂不'}`]"
+)
+
+type IOSDeviceOption func(*IOSDevice)
+
+func WithUDID(udid string) IOSDeviceOption {
+	return func(device *IOSDevice) {
+		device.UDID = udid
+	}
+}
+
+func WithWDAPort(port int) IOSDeviceOption {
+	return func(device *IOSDevice) {
+		device.Port = port
+	}
+}
+
+func WithWDAMjpegPort(port int) IOSDeviceOption {
+	return func(device *IOSDevice) {
+		device.MjpegPort = port
+	}
+}
+
+func WithLogOn(logOn bool) IOSDeviceOption {
+	return func(device *IOSDevice) {
+		device.LogOn = logOn
+	}
+}
+
+func WithResetHomeOnStartup(reset bool) IOSDeviceOption {
+	return func(device *IOSDevice) {
+		device.ResetHomeOnStartup = reset
+	}
+}
+
+func WithSnapshotMaxDepth(depth int) IOSDeviceOption {
+	return func(device *IOSDevice) {
+		device.SnapshotMaxDepth = depth
+	}
+}
+
+func WithAcceptAlertButtonSelector(selector string) IOSDeviceOption {
+	return func(device *IOSDevice) {
+		device.AcceptAlertButtonSelector = selector
+	}
+}
+
+func WithDismissAlertButtonSelector(selector string) IOSDeviceOption {
+	return func(device *IOSDevice) {
+		device.DismissAlertButtonSelector = selector
+	}
+}
+
+func WithPerfOptions(options ...giDevice.PerfOption) IOSDeviceOption {
+	return func(device *IOSDevice) {
+		device.PerfOptions = &giDevice.PerfOptions{}
+		for _, option := range options {
+			option(device.PerfOptions)
+		}
+	}
+}
+
+func IOSDevices(udid ...string) (devices []giDevice.Device, err error) {
+	var usbmux giDevice.Usbmux
+	if usbmux, err = giDevice.NewUsbmux(); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceConnectionError,
+			fmt.Sprintf("init usbmux failed: %v", err))
+	}
+
+	if devices, err = usbmux.Devices(); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceConnectionError,
+			fmt.Sprintf("list ios devices failed: %v", err))
+	}
+
+	// filter by udid
+	var deviceList []giDevice.Device
+	for _, d := range devices {
+		for _, u := range udid {
+			if u != "" && u != d.Properties().SerialNumber {
+				continue
+			}
+			deviceList = append(deviceList, d)
+		}
+	}
+
+	return deviceList, nil
+}
+
+func GetIOSDeviceOptions(dev *IOSDevice) (deviceOptions []IOSDeviceOption) {
+	if dev.UDID != "" {
+		deviceOptions = append(deviceOptions, WithUDID(dev.UDID))
+	}
+	if dev.Port != 0 {
+		deviceOptions = append(deviceOptions, WithWDAPort(dev.Port))
+	}
+	if dev.MjpegPort != 0 {
+		deviceOptions = append(deviceOptions, WithWDAMjpegPort(dev.MjpegPort))
+	}
+	if dev.LogOn {
+		deviceOptions = append(deviceOptions, WithLogOn(true))
+	}
+	if dev.PerfOptions != nil {
+		deviceOptions = append(deviceOptions, WithPerfOptions(dev.perfOpitons()...))
+	}
+	if dev.ResetHomeOnStartup {
+		deviceOptions = append(deviceOptions, WithResetHomeOnStartup(true))
+	}
+	if dev.SnapshotMaxDepth != 0 {
+		deviceOptions = append(deviceOptions, WithSnapshotMaxDepth(dev.SnapshotMaxDepth))
+	}
+	if dev.AcceptAlertButtonSelector != "" {
+		deviceOptions = append(deviceOptions, WithAcceptAlertButtonSelector(dev.AcceptAlertButtonSelector))
+	}
+	if dev.DismissAlertButtonSelector != "" {
+		deviceOptions = append(deviceOptions, WithDismissAlertButtonSelector(dev.DismissAlertButtonSelector))
+	}
+	return
+}
+
+func NewIOSDevice(options ...IOSDeviceOption) (device *IOSDevice, err error) {
+	device = &IOSDevice{
+		Port:                       defaultWDAPort,
+		MjpegPort:                  defaultMjpegPort,
+		SnapshotMaxDepth:           snapshotMaxDepth,
+		AcceptAlertButtonSelector:  acceptAlertButtonSelector,
+		DismissAlertButtonSelector: dismissAlertButtonSelector,
+		// switch to iOS springboard before init WDA session
+		// avoid getting stuck when some super app is active such as douyin or wexin
+		ResetHomeOnStartup: true,
+	}
+	for _, option := range options {
+		option(device)
+	}
+
+	deviceList, err := IOSDevices(device.UDID)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(deviceList) > 0 {
+		device.UDID = deviceList[0].Properties().SerialNumber
+		log.Info().Str("udid", device.UDID).Msg("select device")
+		device.d = deviceList[0]
+		return device, nil
+	}
+
+	return nil, errors.Wrap(code.IOSDeviceConnectionError,
+		fmt.Sprintf("device %s not found", device.UDID))
+}
+
+type IOSDevice struct {
+	d           giDevice.Device
+	PerfOptions *giDevice.PerfOptions `json:"perf_options,omitempty" yaml:"perf_options,omitempty"`
+	UDID        string                `json:"udid,omitempty" yaml:"udid,omitempty"`
+	Port        int                   `json:"port,omitempty" yaml:"port,omitempty"`             // WDA remote port
+	MjpegPort   int                   `json:"mjpeg_port,omitempty" yaml:"mjpeg_port,omitempty"` // WDA remote MJPEG port
+	LogOn       bool                  `json:"log_on,omitempty" yaml:"log_on,omitempty"`
+
+	// switch to iOS springboard before init WDA session
+	ResetHomeOnStartup bool `json:"reset_home_on_startup,omitempty" yaml:"reset_home_on_startup,omitempty"`
+
+	// config appium settings
+	SnapshotMaxDepth           int    `json:"snapshot_max_depth,omitempty" yaml:"snapshot_max_depth,omitempty"`
+	AcceptAlertButtonSelector  string `json:"accept_alert_button_selector,omitempty" yaml:"accept_alert_button_selector,omitempty"`
+	DismissAlertButtonSelector string `json:"dismiss_alert_button_selector,omitempty" yaml:"dismiss_alert_button_selector,omitempty"`
+}
+
+func (dev *IOSDevice) UUID() string {
+	return dev.UDID
+}
+
+func (dev *IOSDevice) NewDriver(capabilities Capabilities) (driverExt *DriverExt, err error) {
+	// init WDA driver
+	if capabilities == nil {
+		capabilities = NewCapabilities()
+		capabilities.WithDefaultAlertAction(AlertActionAccept)
+	}
+
+	var driver WebDriver
+	if env.WDA_USB_DRIVER == "" {
+		// default use http driver
+		driver, err = dev.NewHTTPDriver(capabilities)
+	} else {
+		driver, err = dev.NewUSBDriver(capabilities)
+	}
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to init WDA driver")
+	}
+
+	if dev.ResetHomeOnStartup {
+		log.Info().Msg("go back to home screen")
+		if err = driver.Homescreen(); err != nil {
+			return nil, errors.Wrap(code.MobileUIDriverError,
+				fmt.Sprintf("go back to home screen failed: %v", err))
+		}
+	}
+
+	driverExt, err = Extend(driver)
+	if err != nil {
+		return nil, errors.Wrap(code.MobileUIDriverError,
+			fmt.Sprintf("extend WebDriver failed: %v", err))
+	}
+	settings, err := driverExt.Driver.SetAppiumSettings(map[string]interface{}{
+		"snapshotMaxDepth":          dev.SnapshotMaxDepth,
+		"acceptAlertButtonSelector": dev.AcceptAlertButtonSelector,
+	})
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to set appium WDA settings")
+	}
+	log.Info().Interface("appiumWDASettings", settings).Msg("set appium WDA settings")
+
+	if dev.LogOn {
+		err = driverExt.Driver.StartCaptureLog("hrp_wda_log")
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if dev.PerfOptions != nil {
+		data, err := dev.d.PerfStart(dev.perfOpitons()...)
+		if err != nil {
+			return nil, err
+		}
+
+		driverExt.perfStop = make(chan struct{})
+		// start performance monitor
+		go func() {
+			for {
+				select {
+				case <-driverExt.perfStop:
+					dev.d.PerfStop()
+					return
+				case d := <-data:
+					fmt.Println(string(d))
+					driverExt.perfData = append(driverExt.perfData, string(d))
+				}
+			}
+		}()
+	}
+
+	driverExt.UUID = dev.UUID()
+	return driverExt, nil
+}
+
+func (dev *IOSDevice) forward(localPort, remotePort int) error {
+	log.Info().Int("localPort", localPort).Int("remotePort", remotePort).
+		Str("udid", dev.UDID).Msg("forward tcp port")
+
+	listener, err := net.Listen("tcp", fmt.Sprintf(":%d", localPort))
+	if err != nil {
+		log.Error().Err(err).Msg("listen tcp error")
+		return err
+	}
+
+	go func(listener net.Listener, device giDevice.Device) {
+		for {
+			accept, err := listener.Accept()
+			if err != nil {
+				log.Error().Err(err).Msg("accept error")
+				continue
+			}
+
+			rInnerConn, err := device.NewConnect(remotePort)
+			if err != nil {
+				log.Error().Err(err).Msg("connect to ios device failed")
+				os.Exit(code.GetErrorCode(code.IOSDeviceConnectionError))
+			}
+
+			rConn := rInnerConn.RawConn()
+			_ = rConn.SetDeadline(time.Time{})
+
+			go func(lConn net.Conn) {
+				go func(lConn, rConn net.Conn) {
+					if _, err := io.Copy(lConn, rConn); err != nil {
+						log.Error().Err(err).Msg("copy local -> remote")
+					}
+				}(lConn, rConn)
+				go func(lConn, rConn net.Conn) {
+					if _, err := io.Copy(rConn, lConn); err != nil {
+						log.Error().Err(err).Msg("copy local <- remote")
+					}
+				}(lConn, rConn)
+			}(accept)
+		}
+	}(listener, dev.d)
+
+	return nil
+}
+
+func (dev *IOSDevice) perfOpitons() (perfOptions []giDevice.PerfOption) {
+	if dev.PerfOptions == nil {
+		return
+	}
+
+	// system
+	if dev.PerfOptions.SysCPU {
+		perfOptions = append(perfOptions, giDevice.WithPerfSystemCPU(true))
+	}
+	if dev.PerfOptions.SysMem {
+		perfOptions = append(perfOptions, giDevice.WithPerfSystemMem(true))
+	}
+	if dev.PerfOptions.SysDisk {
+		perfOptions = append(perfOptions, giDevice.WithPerfSystemDisk(true))
+	}
+	if dev.PerfOptions.SysNetwork {
+		perfOptions = append(perfOptions, giDevice.WithPerfSystemNetwork(true))
+	}
+	if dev.PerfOptions.FPS {
+		perfOptions = append(perfOptions, giDevice.WithPerfFPS(true))
+	}
+	if dev.PerfOptions.Network {
+		perfOptions = append(perfOptions, giDevice.WithPerfNetwork(true))
+	}
+
+	// process
+	if dev.PerfOptions.BundleID != "" {
+		perfOptions = append(perfOptions,
+			giDevice.WithPerfBundleID(dev.PerfOptions.BundleID))
+	}
+	if dev.PerfOptions.Pid != 0 {
+		perfOptions = append(perfOptions,
+			giDevice.WithPerfPID(dev.PerfOptions.Pid))
+	}
+
+	// config
+	if dev.PerfOptions.OutputInterval != 0 {
+		perfOptions = append(perfOptions,
+			giDevice.WithPerfOutputInterval(dev.PerfOptions.OutputInterval))
+	}
+	if dev.PerfOptions.SystemAttributes != nil {
+		perfOptions = append(perfOptions,
+			giDevice.WithPerfSystemAttributes(dev.PerfOptions.SystemAttributes...))
+	}
+	if dev.PerfOptions.ProcessAttributes != nil {
+		perfOptions = append(perfOptions,
+			giDevice.WithPerfProcessAttributes(dev.PerfOptions.ProcessAttributes...))
+	}
+	return
+}
+
+// NewHTTPDriver creates new remote HTTP client, this will also start a new session.
+func (dev *IOSDevice) NewHTTPDriver(capabilities Capabilities) (driver WebDriver, err error) {
+	localPort, err := getFreePort()
+	if err != nil {
+		return nil, errors.Wrap(code.IOSDeviceHTTPDriverError,
+			fmt.Sprintf("get free port failed: %v", err))
+	}
+	if err = dev.forward(localPort, dev.Port); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceHTTPDriverError,
+			fmt.Sprintf("forward tcp port failed: %v", err))
+	}
+	localMjpegPort, err := getFreePort()
+	if err != nil {
+		return nil, errors.Wrap(code.IOSDeviceHTTPDriverError,
+			fmt.Sprintf("get free port failed: %v", err))
+	}
+	if err = dev.forward(localMjpegPort, dev.MjpegPort); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceHTTPDriverError,
+			fmt.Sprintf("forward tcp port failed: %v", err))
+	}
+
+	log.Info().Interface("capabilities", capabilities).
+		Int("localPort", localPort).Int("localMjpegPort", localMjpegPort).
+		Msg("init WDA HTTP driver")
+
+	wd := new(wdaDriver)
+	wd.client = http.DefaultClient
+
+	host := "127.0.0.1"
+	if wd.urlPrefix, err = url.Parse(fmt.Sprintf("http://%s:%d", host, localPort)); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceHTTPDriverError, err.Error())
+	}
+	var sessionInfo SessionInfo
+	if sessionInfo, err = wd.NewSession(capabilities); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceHTTPDriverError, err.Error())
+	}
+	wd.sessionId = sessionInfo.SessionId
+
+	if wd.mjpegHTTPConn, err = net.Dial(
+		"tcp",
+		fmt.Sprintf("%s:%d", host, localMjpegPort),
+	); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceHTTPDriverError, err.Error())
+	}
+	wd.mjpegClient = convertToHTTPClient(wd.mjpegHTTPConn)
+
+	return wd, nil
+}
+
+// NewUSBDriver creates new client via USB connected device, this will also start a new session.
+func (dev *IOSDevice) NewUSBDriver(capabilities Capabilities) (driver WebDriver, err error) {
+	log.Info().Interface("capabilities", capabilities).
+		Str("udid", dev.UDID).Msg("init WDA USB driver")
+
+	wd := new(wdaDriver)
+	if wd.defaultConn, err = dev.d.NewConnect(dev.Port, 0); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceUSBDriverError,
+			fmt.Sprintf("connect port %d failed: %v", dev.Port, err))
+	}
+	wd.client = convertToHTTPClient(wd.defaultConn.RawConn())
+
+	if wd.mjpegUSBConn, err = dev.d.NewConnect(dev.MjpegPort, 0); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceUSBDriverError,
+			fmt.Sprintf("connect MJPEG port %d failed: %v", dev.MjpegPort, err))
+	}
+	wd.mjpegClient = convertToHTTPClient(wd.mjpegUSBConn.RawConn())
+
+	if wd.urlPrefix, err = url.Parse("http://" + dev.UDID); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceUSBDriverError, err.Error())
+	}
+	if _, err = wd.NewSession(capabilities); err != nil {
+		return nil, errors.Wrap(code.IOSDeviceUSBDriverError, err.Error())
+	}
+
+	return wd, nil
+}
+
+func (dExt *DriverExt) ConnectMjpegStream(httpClient *http.Client) (err error) {
+	if httpClient == nil {
+		return errors.New(`'httpClient' can't be nil`)
+	}
+
+	var req *http.Request
+	if req, err = http.NewRequest(http.MethodGet, "http://*", nil); err != nil {
+		return err
+	}
+
+	var resp *http.Response
+	if resp, err = httpClient.Do(req); err != nil {
+		return err
+	}
+	// defer func() { _ = resp.Body.Close() }()
+
+	var boundary string
+	if _, param, err := mime.ParseMediaType(resp.Header.Get("Content-Type")); err != nil {
+		return err
+	} else {
+		boundary = strings.Trim(param["boundary"], "-")
+	}
+
+	mjpegReader := multipart.NewReader(resp.Body, boundary)
+
+	go func() {
+		for {
+			select {
+			case <-dExt.doneMjpegStream:
+				_ = resp.Body.Close()
+				return
+			default:
+				var part *multipart.Part
+				if part, err = mjpegReader.NextPart(); err != nil {
+					dExt.frame = nil
+					continue
+				}
+
+				raw := new(bytes.Buffer)
+				if _, err = raw.ReadFrom(part); err != nil {
+					dExt.frame = nil
+					continue
+				}
+				dExt.frame = raw
+			}
+		}
+	}()
+
+	return
+}
+
+func (dExt *DriverExt) CloseMjpegStream() {
+	dExt.doneMjpegStream <- true
+}
+
+type rawResponse []byte
+
+func (r rawResponse) checkErr() (err error) {
+	reply := new(struct {
+		Value struct {
+			Err        string `json:"error"`
+			Message    string `json:"message"`
+			Traceback  string `json:"traceback"`  // wda
+			Stacktrace string `json:"stacktrace"` // uia
+		}
+	})
+	if err = json.Unmarshal(r, reply); err != nil {
+		return err
+	}
+	if reply.Value.Err != "" {
+		errText := reply.Value.Message
+		re := regexp.MustCompile(`{.+?=(.+?)}`)
+		if re.MatchString(reply.Value.Message) {
+			subMatch := re.FindStringSubmatch(reply.Value.Message)
+			errText = subMatch[len(subMatch)-1]
+		}
+		return fmt.Errorf("%s: %s", reply.Value.Err, errText)
+	}
+	return
+}
+
+func (r rawResponse) valueConvertToString() (s string, err error) {
+	reply := new(struct{ Value string })
+	if err = json.Unmarshal(r, reply); err != nil {
+		return "", errors.Wrapf(err, "json.Unmarshal failed, rawResponse: %s", string(r))
+	}
+	s = reply.Value
+	return
+}
+
+func (r rawResponse) valueConvertToBool() (b bool, err error) {
+	reply := new(struct{ Value bool })
+	if err = json.Unmarshal(r, reply); err != nil {
+		return false, err
+	}
+	b = reply.Value
+	return
+}
+
+func (r rawResponse) valueConvertToSessionInfo() (sessionInfo SessionInfo, err error) {
+	reply := new(struct{ Value struct{ SessionInfo } })
+	if err = json.Unmarshal(r, reply); err != nil {
+		return SessionInfo{}, err
+	}
+	sessionInfo = reply.Value.SessionInfo
+	return
+}
+
+func (r rawResponse) valueConvertToJsonRawMessage() (raw builtinJSON.RawMessage, err error) {
+	reply := new(struct{ Value builtinJSON.RawMessage })
+	if err = json.Unmarshal(r, reply); err != nil {
+		return nil, err
+	}
+	raw = reply.Value
+	return
+}
+
+func (r rawResponse) valueDecodeAsBase64() (raw *bytes.Buffer, err error) {
+	str, err := r.valueConvertToString()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to convert value to string")
+	}
+	decodeString, err := base64.StdEncoding.DecodeString(str)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to decode base64 string")
+	}
+	raw = bytes.NewBuffer(decodeString)
+	return
+}
+
+var errNoSuchElement = errors.New("no such element")
+
+func (r rawResponse) valueConvertToElementID() (id string, err error) {
+	reply := new(struct{ Value map[string]string })
+	if err = json.Unmarshal(r, reply); err != nil {
+		return "", err
+	}
+	if len(reply.Value) == 0 {
+		return "", errNoSuchElement
+	}
+	if id = elementIDFromValue(reply.Value); id == "" {
+		return "", fmt.Errorf("invalid element returned: %+v", reply)
+	}
+	return
+}
+
+func (r rawResponse) valueConvertToElementIDs() (IDs []string, err error) {
+	reply := new(struct{ Value []map[string]string })
+	if err = json.Unmarshal(r, reply); err != nil {
+		return nil, err
+	}
+	if len(reply.Value) == 0 {
+		return nil, errNoSuchElement
+	}
+	IDs = make([]string, len(reply.Value))
+	for i, elem := range reply.Value {
+		var id string
+		if id = elementIDFromValue(elem); id == "" {
+			return nil, fmt.Errorf("invalid element returned: %+v", reply)
+		}
+		IDs[i] = id
+	}
+	return
+}
diff --git a/hrp/pkg/uixt/ios_driver.go b/hrp/pkg/uixt/ios_driver.go
new file mode 100644
index 000000000..72da39289
--- /dev/null
+++ b/hrp/pkg/uixt/ios_driver.go
@@ -0,0 +1,890 @@
+package uixt
+
+import (
+	"bytes"
+	"encoding/base64"
+	builtinJSON "encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	giDevice "github.com/electricbubble/gidevice"
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
+	"github.com/httprunner/httprunner/v4/hrp/internal/json"
+)
+
+type wdaDriver struct {
+	Driver
+
+	// default port
+	defaultConn giDevice.InnerConn
+
+	// mjpeg port
+	mjpegUSBConn  giDevice.InnerConn // via USB
+	mjpegHTTPConn net.Conn           // via HTTP
+	mjpegClient   *http.Client
+}
+
+func (wd *wdaDriver) GetMjpegClient() *http.Client {
+	return wd.mjpegClient
+}
+
+func (wd *wdaDriver) Close() error {
+	if wd.defaultConn != nil {
+		wd.defaultConn.Close()
+	}
+	if wd.mjpegUSBConn != nil {
+		wd.mjpegUSBConn.Close()
+	}
+
+	if wd.mjpegClient != nil {
+		wd.mjpegClient.CloseIdleConnections()
+	}
+	return wd.mjpegHTTPConn.Close()
+}
+
+func (wd *wdaDriver) NewSession(capabilities Capabilities) (sessionInfo SessionInfo, err error) {
+	// [[FBRoute POST:@"/session"].withoutSession respondWithTarget:self action:@selector(handleCreateSession:)]
+	data := make(map[string]interface{})
+	if len(capabilities) == 0 {
+		data["capabilities"] = make(map[string]interface{})
+	} else {
+		data["capabilities"] = map[string]interface{}{"alwaysMatch": capabilities}
+	}
+
+	var rawResp rawResponse
+	if rawResp, err = wd.httpPOST(data, "/session"); err != nil {
+		return SessionInfo{}, err
+	}
+	if sessionInfo, err = rawResp.valueConvertToSessionInfo(); err != nil {
+		return SessionInfo{}, err
+	}
+	wd.sessionId = sessionInfo.SessionId
+	return
+}
+
+func (wd *wdaDriver) ActiveSession() (sessionInfo SessionInfo, err error) {
+	// [[FBRoute GET:@""] respondWithTarget:self action:@selector(handleGetActiveSession:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId); err != nil {
+		return SessionInfo{}, err
+	}
+	if sessionInfo, err = rawResp.valueConvertToSessionInfo(); err != nil {
+		return SessionInfo{}, err
+	}
+	return
+}
+
+func (wd *wdaDriver) DeleteSession() (err error) {
+	// [[FBRoute DELETE:@""] respondWithTarget:self action:@selector(handleDeleteSession:)]
+	_, err = wd.httpDELETE("/session", wd.sessionId)
+	return
+}
+
+func (wd *wdaDriver) Status() (deviceStatus DeviceStatus, err error) {
+	// [[FBRoute GET:@"/status"].withoutSession respondWithTarget:self action:@selector(handleGetStatus:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/status"); err != nil {
+		return DeviceStatus{}, err
+	}
+	reply := new(struct{ Value struct{ DeviceStatus } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return DeviceStatus{}, err
+	}
+	deviceStatus = reply.Value.DeviceStatus
+	return
+}
+
+func (wd *wdaDriver) DeviceInfo() (deviceInfo DeviceInfo, err error) {
+	// [[FBRoute GET:@"/wda/device/info"] respondWithTarget:self action:@selector(handleGetDeviceInfo:)]
+	// [[FBRoute GET:@"/wda/device/info"].withoutSession
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/wda/device/info"); err != nil {
+		return DeviceInfo{}, err
+	}
+	reply := new(struct{ Value struct{ DeviceInfo } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return DeviceInfo{}, err
+	}
+	deviceInfo = reply.Value.DeviceInfo
+	return
+}
+
+func (wd *wdaDriver) Location() (location Location, err error) {
+	// [[FBRoute GET:@"/wda/device/location"] respondWithTarget:self action:@selector(handleGetLocation:)]
+	// [[FBRoute GET:@"/wda/device/location"].withoutSession
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/wda/device/location"); err != nil {
+		return Location{}, err
+	}
+	reply := new(struct{ Value struct{ Location } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return Location{}, err
+	}
+	location = reply.Value.Location
+	return
+}
+
+func (wd *wdaDriver) BatteryInfo() (batteryInfo BatteryInfo, err error) {
+	// [[FBRoute GET:@"/wda/batteryInfo"] respondWithTarget:self action:@selector(handleGetBatteryInfo:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/wda/batteryInfo"); err != nil {
+		return BatteryInfo{}, err
+	}
+	reply := new(struct{ Value struct{ BatteryInfo } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return BatteryInfo{}, err
+	}
+	batteryInfo = reply.Value.BatteryInfo
+	return
+}
+
+func (wd *wdaDriver) WindowSize() (size Size, err error) {
+	// [[FBRoute GET:@"/window/size"] respondWithTarget:self action:@selector(handleGetWindowSize:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/window/size"); err != nil {
+		return Size{}, err
+	}
+	reply := new(struct{ Value struct{ Size } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return Size{}, err
+	}
+	size = reply.Value.Size
+	return
+}
+
+func (wd *wdaDriver) Screen() (screen Screen, err error) {
+	// [[FBRoute GET:@"/wda/screen"] respondWithTarget:self action:@selector(handleGetScreen:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/wda/screen"); err != nil {
+		return Screen{}, err
+	}
+	reply := new(struct{ Value struct{ Screen } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return Screen{}, err
+	}
+	screen = reply.Value.Screen
+	return
+}
+
+func (wd *wdaDriver) Scale() (float64, error) {
+	screen, err := wd.Screen()
+	if err != nil {
+		return 0, err
+	}
+	return screen.Scale, nil
+}
+
+func (wd *wdaDriver) ActiveAppInfo() (info AppInfo, err error) {
+	// [[FBRoute GET:@"/wda/activeAppInfo"] respondWithTarget:self action:@selector(handleActiveAppInfo:)]
+	// [[FBRoute GET:@"/wda/activeAppInfo"].withoutSession
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/wda/activeAppInfo"); err != nil {
+		return AppInfo{}, err
+	}
+	reply := new(struct{ Value struct{ AppInfo } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return AppInfo{}, err
+	}
+	info = reply.Value.AppInfo
+	return
+}
+
+func (wd *wdaDriver) ActiveAppsList() (appsList []AppBaseInfo, err error) {
+	// [[FBRoute GET:@"/wda/apps/list"] respondWithTarget:self action:@selector(handleGetActiveAppsList:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/wda/apps/list"); err != nil {
+		return nil, err
+	}
+	reply := new(struct{ Value []AppBaseInfo })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+	appsList = reply.Value
+	return
+}
+
+func (wd *wdaDriver) AppState(bundleId string) (runState AppState, err error) {
+	// [[FBRoute POST:@"/wda/apps/state"] respondWithTarget:self action:@selector(handleSessionAppState:)]
+	data := map[string]interface{}{"bundleId": bundleId}
+	var rawResp rawResponse
+	if rawResp, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/apps/state"); err != nil {
+		return 0, err
+	}
+	reply := new(struct{ Value AppState })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return 0, err
+	}
+	runState = reply.Value
+	_ = rawResp
+	return
+}
+
+func (wd *wdaDriver) IsLocked() (locked bool, err error) {
+	// [[FBRoute GET:@"/wda/locked"] respondWithTarget:self action:@selector(handleIsLocked:)]
+	// [[FBRoute GET:@"/wda/locked"].withoutSession
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/wda/locked"); err != nil {
+		return false, err
+	}
+	if locked, err = rawResp.valueConvertToBool(); err != nil {
+		return false, err
+	}
+	return
+}
+
+func (wd *wdaDriver) Unlock() (err error) {
+	// [[FBRoute POST:@"/wda/unlock"] respondWithTarget:self action:@selector(handleUnlock:)]
+	// [[FBRoute POST:@"/wda/unlock"].withoutSession
+	_, err = wd.httpPOST(nil, "/session", wd.sessionId, "/wda/unlock")
+	return
+}
+
+func (wd *wdaDriver) Lock() (err error) {
+	// [[FBRoute POST:@"/wda/lock"] respondWithTarget:self action:@selector(handleLock:)]
+	// [[FBRoute POST:@"/wda/lock"].withoutSession
+	_, err = wd.httpPOST(nil, "/session", wd.sessionId, "/wda/lock")
+	return
+}
+
+func (wd *wdaDriver) Homescreen() (err error) {
+	// [[FBRoute POST:@"/wda/homescreen"].withoutSession respondWithTarget:self action:@selector(handleHomescreenCommand:)]
+	_, err = wd.httpPOST(nil, "/wda/homescreen")
+	return
+}
+
+func (wd *wdaDriver) AlertText() (text string, err error) {
+	// [[FBRoute GET:@"/alert/text"] respondWithTarget:self action:@selector(handleAlertGetTextCommand:)]
+	// [[FBRoute GET:@"/alert/text"].withoutSession
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/alert/text"); err != nil {
+		return "", err
+	}
+	if text, err = rawResp.valueConvertToString(); err != nil {
+		return "", err
+	}
+	return
+}
+
+func (wd *wdaDriver) AlertButtons() (btnLabels []string, err error) {
+	// [[FBRoute GET:@"/wda/alert/buttons"] respondWithTarget:self action:@selector(handleGetAlertButtonsCommand:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/wda/alert/buttons"); err != nil {
+		return nil, err
+	}
+	reply := new(struct{ Value []string })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+	btnLabels = reply.Value
+	return
+}
+
+func (wd *wdaDriver) AlertAccept(label ...string) (err error) {
+	// [[FBRoute POST:@"/alert/accept"] respondWithTarget:self action:@selector(handleAlertAcceptCommand:)]
+	// [[FBRoute POST:@"/alert/accept"].withoutSession
+	data := make(map[string]interface{})
+	if len(label) != 0 && label[0] != "" {
+		data["name"] = label[0]
+	}
+	_, err = wd.httpPOST(data, "/alert/accept")
+	return
+}
+
+func (wd *wdaDriver) AlertDismiss(label ...string) (err error) {
+	// [[FBRoute POST:@"/alert/dismiss"] respondWithTarget:self action:@selector(handleAlertDismissCommand:)]
+	// [[FBRoute POST:@"/alert/dismiss"].withoutSession
+	data := make(map[string]interface{})
+	if len(label) != 0 && label[0] != "" {
+		data["name"] = label[0]
+	}
+	_, err = wd.httpPOST(data, "/alert/dismiss")
+	return
+}
+
+func (wd *wdaDriver) AlertSendKeys(text string) (err error) {
+	// [[FBRoute POST:@"/alert/text"] respondWithTarget:self action:@selector(handleAlertSetTextCommand:)]
+	data := map[string]interface{}{"value": strings.Split(text, "")}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/alert/text")
+	return
+}
+
+func (wd *wdaDriver) AppLaunch(bundleId string, launchOpt ...AppLaunchOption) (err error) {
+	// [[FBRoute POST:@"/wda/apps/launch"] respondWithTarget:self action:@selector(handleSessionAppLaunch:)]
+	data := make(map[string]interface{})
+	if len(launchOpt) != 0 {
+		data = launchOpt[0]
+	}
+	data["bundleId"] = bundleId
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/apps/launch")
+	return
+}
+
+func (wd *wdaDriver) AppLaunchUnattached(bundleId string) (err error) {
+	// [[FBRoute POST:@"/wda/apps/launchUnattached"].withoutSession respondWithTarget:self action:@selector(handleLaunchUnattachedApp:)]
+	data := map[string]interface{}{"bundleId": bundleId}
+	_, err = wd.httpPOST(data, "/wda/apps/launchUnattached")
+	return
+}
+
+func (wd *wdaDriver) AppTerminate(bundleId string) (successful bool, err error) {
+	// [[FBRoute POST:@"/wda/apps/terminate"] respondWithTarget:self action:@selector(handleSessionAppTerminate:)]
+	data := map[string]interface{}{"bundleId": bundleId}
+	var rawResp rawResponse
+	if rawResp, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/apps/terminate"); err != nil {
+		return false, err
+	}
+	if successful, err = rawResp.valueConvertToBool(); err != nil {
+		return false, err
+	}
+	return
+}
+
+func (wd *wdaDriver) AppActivate(bundleId string) (err error) {
+	// [[FBRoute POST:@"/wda/apps/activate"] respondWithTarget:self action:@selector(handleSessionAppActivate:)]
+	data := map[string]interface{}{"bundleId": bundleId}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/apps/activate")
+	return
+}
+
+func (wd *wdaDriver) AppDeactivate(second float64) (err error) {
+	// [[FBRoute POST:@"/wda/deactivateApp"] respondWithTarget:self action:@selector(handleDeactivateAppCommand:)]
+	if second < 3 {
+		second = 3.0
+	}
+	data := map[string]interface{}{"duration": second}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/deactivateApp")
+	return
+}
+
+func (wd *wdaDriver) AppAuthReset(resource ProtectedResource) (err error) {
+	// [[FBRoute POST:@"/wda/resetAppAuth"] respondWithTarget:self action:@selector(handleResetAppAuth:)]
+	data := map[string]interface{}{"resource": resource}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/resetAppAuth")
+	return
+}
+
+func (wd *wdaDriver) Tap(x, y int, options ...DataOption) error {
+	return wd.TapFloat(float64(x), float64(y), options...)
+}
+
+func (wd *wdaDriver) TapFloat(x, y float64, options ...DataOption) (err error) {
+	// [[FBRoute POST:@"/wda/tap/:uuid"] respondWithTarget:self action:@selector(handleTap:)]
+	data := map[string]interface{}{
+		"x": x,
+		"y": y,
+	}
+	// new data options in post data for extra WDA configurations
+	d := NewData(data, options...)
+
+	_, err = wd.httpPOST(d.Data, "/session", wd.sessionId, "/wda/tap/0")
+	return
+}
+
+func (wd *wdaDriver) DoubleTap(x, y int) error {
+	return wd.DoubleTapFloat(float64(x), float64(y))
+}
+
+func (wd *wdaDriver) DoubleTapFloat(x, y float64) (err error) {
+	// [[FBRoute POST:@"/wda/doubleTap"] respondWithTarget:self action:@selector(handleDoubleTapCoordinate:)]
+	data := map[string]interface{}{
+		"x": x,
+		"y": y,
+	}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/doubleTap")
+	return
+}
+
+func (wd *wdaDriver) TouchAndHold(x, y int, second ...float64) error {
+	return wd.TouchAndHoldFloat(float64(x), float64(y), second...)
+}
+
+func (wd *wdaDriver) TouchAndHoldFloat(x, y float64, second ...float64) (err error) {
+	// [[FBRoute POST:@"/wda/touchAndHold"] respondWithTarget:self action:@selector(handleTouchAndHoldCoordinate:)]
+	data := map[string]interface{}{
+		"x": x,
+		"y": y,
+	}
+	if len(second) == 0 || second[0] <= 0 {
+		second = []float64{1.0}
+	}
+	data["duration"] = second[0]
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/touchAndHold")
+	return
+}
+
+func (wd *wdaDriver) Drag(fromX, fromY, toX, toY int, options ...DataOption) error {
+	return wd.DragFloat(float64(fromX), float64(fromY), float64(toX), float64(toY), options...)
+}
+
+func (wd *wdaDriver) DragFloat(fromX, fromY, toX, toY float64, options ...DataOption) (err error) {
+	// [[FBRoute POST:@"/wda/dragfromtoforduration"] respondWithTarget:self action:@selector(handleDragCoordinate:)]
+	data := map[string]interface{}{
+		"fromX": fromX,
+		"fromY": fromY,
+		"toX":   toX,
+		"toY":   toY,
+	}
+
+	// new data options in post data for extra WDA configurations
+	d := NewData(data, options...)
+
+	_, err = wd.httpPOST(d.Data, "/session", wd.sessionId, "/wda/dragfromtoforduration")
+	return
+}
+
+func (wd *wdaDriver) Swipe(fromX, fromY, toX, toY int, options ...DataOption) error {
+	options = append(options, WithDataPressDuration(0))
+	return wd.SwipeFloat(float64(fromX), float64(fromY), float64(toX), float64(toY), options...)
+}
+
+func (wd *wdaDriver) SwipeFloat(fromX, fromY, toX, toY float64, options ...DataOption) error {
+	options = append(options, WithDataPressDuration(0))
+	return wd.DragFloat(fromX, fromY, toX, toY, options...)
+}
+
+func (wd *wdaDriver) ForceTouch(x, y int, pressure float64, second ...float64) error {
+	return wd.ForceTouchFloat(float64(x), float64(y), pressure, second...)
+}
+
+func (wd *wdaDriver) ForceTouchFloat(x, y, pressure float64, second ...float64) error {
+	if len(second) == 0 || second[0] <= 0 {
+		second = []float64{1.0}
+	}
+	actions := NewTouchActions().
+		Press(
+			NewTouchActionPress().WithXYFloat(x, y).WithPressure(pressure)).
+		Wait(second[0]).
+		Release()
+	return wd.PerformAppiumTouchActions(actions)
+}
+
+func (wd *wdaDriver) PerformW3CActions(actions *W3CActions) (err error) {
+	// [[FBRoute POST:@"/actions"] respondWithTarget:self action:@selector(handlePerformW3CTouchActions:)]
+	data := map[string]interface{}{"actions": actions}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/actions")
+	return
+}
+
+func (wd *wdaDriver) PerformAppiumTouchActions(touchActs *TouchActions) (err error) {
+	// [[FBRoute POST:@"/wda/touch/perform"] respondWithTarget:self action:@selector(handlePerformAppiumTouchActions:)]
+	// [[FBRoute POST:@"/wda/touch/multi/perform"]
+	data := map[string]interface{}{"actions": touchActs}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/touch/multi/perform")
+	return
+}
+
+func (wd *wdaDriver) SetPasteboard(contentType PasteboardType, content string) (err error) {
+	// [[FBRoute POST:@"/wda/setPasteboard"] respondWithTarget:self action:@selector(handleSetPasteboard:)]
+	data := map[string]interface{}{
+		"contentType": contentType,
+		"content":     base64.StdEncoding.EncodeToString([]byte(content)),
+	}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/setPasteboard")
+	return
+}
+
+func (wd *wdaDriver) GetPasteboard(contentType PasteboardType) (raw *bytes.Buffer, err error) {
+	// [[FBRoute POST:@"/wda/getPasteboard"] respondWithTarget:self action:@selector(handleGetPasteboard:)]
+	data := map[string]interface{}{"contentType": contentType}
+	var rawResp rawResponse
+	if rawResp, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/getPasteboard"); err != nil {
+		return nil, err
+	}
+	if raw, err = rawResp.valueDecodeAsBase64(); err != nil {
+		return nil, err
+	}
+	return
+}
+
+func (wd *wdaDriver) SendKeys(text string, options ...DataOption) (err error) {
+	// [[FBRoute POST:@"/wda/keys"] respondWithTarget:self action:@selector(handleKeys:)]
+	data := map[string]interface{}{"value": strings.Split(text, "")}
+
+	// new data options in post data for extra WDA configurations
+	d := NewData(data, options...)
+
+	_, err = wd.httpPOST(d.Data, "/session", wd.sessionId, "/wda/keys")
+	return
+}
+
+func (wd *wdaDriver) Input(text string, options ...DataOption) (err error) {
+	return wd.SendKeys(text, options...)
+}
+
+func (wd *wdaDriver) KeyboardDismiss(keyNames ...string) (err error) {
+	// [[FBRoute POST:@"/wda/keyboard/dismiss"] respondWithTarget:self action:@selector(handleDismissKeyboardCommand:)]
+	if len(keyNames) == 0 {
+		keyNames = []string{"return"}
+	}
+	data := map[string]interface{}{"keyNames": keyNames}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/keyboard/dismiss")
+	return
+}
+
+func (wd *wdaDriver) PressButton(devBtn DeviceButton) (err error) {
+	// [[FBRoute POST:@"/wda/pressButton"] respondWithTarget:self action:@selector(handlePressButtonCommand:)]
+	data := map[string]interface{}{"name": devBtn}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/pressButton")
+	return
+}
+
+func (wd *wdaDriver) IOHIDEvent(pageID EventPageID, usageID EventUsageID, duration ...float64) (err error) {
+	// [[FBRoute POST:@"/wda/performIoHidEvent"] respondWithTarget:self action:@selector(handlePeformIOHIDEvent:)]
+	if len(duration) == 0 || duration[0] <= 0 {
+		duration = []float64{0.005}
+	}
+	data := map[string]interface{}{
+		"page":     pageID,
+		"usage":    usageID,
+		"duration": duration[0],
+	}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/performIoHidEvent")
+	return
+}
+
+func (wd *wdaDriver) StartCamera() (err error) {
+	// start camera, alias for app_launch com.apple.camera
+	return wd.AppLaunch("com.apple.camera")
+}
+
+func (wd *wdaDriver) StopCamera() (err error) {
+	// stop camera, alias for app_terminate com.apple.camera
+	success, err := wd.AppTerminate("com.apple.camera")
+	if err != nil {
+		return errors.Wrap(err, "failed to terminate camera")
+	}
+	if !success {
+		log.Warn().Msg("camera was not running")
+	}
+	return nil
+}
+
+func (wd *wdaDriver) ExpectNotification(notifyName string, notifyType NotificationType, second ...int) (err error) {
+	// [[FBRoute POST:@"/wda/expectNotification"] respondWithTarget:self action:@selector(handleExpectNotification:)]
+	if len(second) == 0 {
+		second = []int{60}
+	}
+	data := map[string]interface{}{
+		"name":    notifyName,
+		"type":    notifyType,
+		"timeout": second[0],
+	}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/expectNotification")
+	return
+}
+
+func (wd *wdaDriver) SiriActivate(text string) (err error) {
+	// [[FBRoute POST:@"/wda/siri/activate"] respondWithTarget:self action:@selector(handleActivateSiri:)]
+	data := map[string]interface{}{"text": text}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/siri/activate")
+	return
+}
+
+func (wd *wdaDriver) SiriOpenUrl(url string) (err error) {
+	// [[FBRoute POST:@"/url"] respondWithTarget:self action:@selector(handleOpenURL:)]
+	data := map[string]interface{}{"url": url}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/url")
+	return
+}
+
+func (wd *wdaDriver) Orientation() (orientation Orientation, err error) {
+	// [[FBRoute GET:@"/orientation"] respondWithTarget:self action:@selector(handleGetOrientation:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/orientation"); err != nil {
+		return "", err
+	}
+	reply := new(struct{ Value Orientation })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return "", err
+	}
+	orientation = reply.Value
+	return
+}
+
+func (wd *wdaDriver) SetOrientation(orientation Orientation) (err error) {
+	// [[FBRoute POST:@"/orientation"] respondWithTarget:self action:@selector(handleSetOrientation:)]
+	data := map[string]interface{}{"orientation": orientation}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/orientation")
+	return
+}
+
+func (wd *wdaDriver) Rotation() (rotation Rotation, err error) {
+	// [[FBRoute GET:@"/rotation"] respondWithTarget:self action:@selector(handleGetRotation:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/rotation"); err != nil {
+		return Rotation{}, err
+	}
+	reply := new(struct{ Value Rotation })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return Rotation{}, err
+	}
+	rotation = reply.Value
+	return
+}
+
+func (wd *wdaDriver) SetRotation(rotation Rotation) (err error) {
+	// [[FBRoute POST:@"/rotation"] respondWithTarget:self action:@selector(handleSetRotation:)]
+	_, err = wd.httpPOST(rotation, "/session", wd.sessionId, "/rotation")
+	return
+}
+
+func (wd *wdaDriver) MatchTouchID(isMatch bool) (err error) {
+	// [FBRoute POST:@"/wda/touch_id"]
+	data := map[string]interface{}{"match": isMatch}
+	_, err = wd.httpPOST(data, "/session", wd.sessionId, "/wda/touch_id")
+	return
+}
+
+func (wd *wdaDriver) ActiveElement() (element WebElement, err error) {
+	// [[FBRoute GET:@"/element/active"] respondWithTarget:self action:@selector(handleGetActiveElement:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/element/active"); err != nil {
+		return nil, err
+	}
+	var elementID string
+	if elementID, err = rawResp.valueConvertToElementID(); err != nil {
+		return nil, err
+	}
+	element = &wdaElement{parent: wd, id: elementID}
+	return
+}
+
+func (wd *wdaDriver) FindElement(by BySelector) (element WebElement, err error) {
+	// [[FBRoute POST:@"/element"] respondWithTarget:self action:@selector(handleFindElement:)]
+	using, value := by.getUsingAndValue()
+	data := map[string]interface{}{
+		"using": using,
+		"value": value,
+	}
+	var rawResp rawResponse
+	if rawResp, err = wd.httpPOST(data, "/session", wd.sessionId, "/element"); err != nil {
+		return nil, err
+	}
+	var elementID string
+	if elementID, err = rawResp.valueConvertToElementID(); err != nil {
+		if errors.Is(err, errNoSuchElement) {
+			return nil, fmt.Errorf("%w: unable to find an element using '%s', value '%s'", err, using, value)
+		}
+		return nil, err
+	}
+	element = &wdaElement{parent: wd, id: elementID}
+	return
+}
+
+func (wd *wdaDriver) FindElements(by BySelector) (elements []WebElement, err error) {
+	// [[FBRoute POST:@"/elements"] respondWithTarget:self action:@selector(handleFindElements:)]
+	using, value := by.getUsingAndValue()
+	data := map[string]interface{}{
+		"using": using,
+		"value": value,
+	}
+	var rawResp rawResponse
+	if rawResp, err = wd.httpPOST(data, "/session", wd.sessionId, "/elements"); err != nil {
+		return nil, err
+	}
+	var elementIDs []string
+	if elementIDs, err = rawResp.valueConvertToElementIDs(); err != nil {
+		if errors.Is(err, errNoSuchElement) {
+			return nil, fmt.Errorf("%w: unable to find an element using '%s', value '%s'", err, using, value)
+		}
+		return nil, err
+	}
+	elements = make([]WebElement, len(elementIDs))
+	for i := range elementIDs {
+		elements[i] = &wdaElement{parent: wd, id: elementIDs[i]}
+	}
+	return
+}
+
+func (wd *wdaDriver) Screenshot() (raw *bytes.Buffer, err error) {
+	// [[FBRoute GET:@"/screenshot"] respondWithTarget:self action:@selector(handleGetScreenshot:)]
+	// [[FBRoute GET:@"/screenshot"].withoutSession respondWithTarget:self action:@selector(handleGetScreenshot:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/screenshot"); err != nil {
+		return nil, errors.Wrap(code.IOSScreenShotError,
+			fmt.Sprintf("get WDA screenshot data failed: %v", err))
+	}
+
+	if raw, err = rawResp.valueDecodeAsBase64(); err != nil {
+		return nil, errors.Wrap(code.IOSScreenShotError,
+			fmt.Sprintf("decode WDA screenshot data failed: %v", err))
+	}
+	return
+}
+
+func (wd *wdaDriver) Source(srcOpt ...SourceOption) (source string, err error) {
+	// [[FBRoute GET:@"/source"] respondWithTarget:self action:@selector(handleGetSourceCommand:)]
+	// [[FBRoute GET:@"/source"].withoutSession
+	tmp, _ := url.Parse(wd.concatURL(nil, "/session", wd.sessionId))
+	toJsonRaw := false
+	if len(srcOpt) != 0 {
+		q := tmp.Query()
+		for k, val := range srcOpt[0] {
+			v := val.(string)
+			q.Set(k, v)
+			if k == "format" && v == "json" {
+				toJsonRaw = true
+			}
+		}
+		tmp.RawQuery = q.Encode()
+	}
+
+	var rawResp rawResponse
+	if rawResp, err = wd.httpRequest(http.MethodGet, wd.concatURL(tmp, "/source"), nil); err != nil {
+		return "", nil
+	}
+	if toJsonRaw {
+		var jr builtinJSON.RawMessage
+		if jr, err = rawResp.valueConvertToJsonRawMessage(); err != nil {
+			return "", err
+		}
+		return string(jr), nil
+	}
+	if source, err = rawResp.valueConvertToString(); err != nil {
+		return "", err
+	}
+	return
+}
+
+func (wd *wdaDriver) AccessibleSource() (source string, err error) {
+	// [[FBRoute GET:@"/wda/accessibleSource"] respondWithTarget:self action:@selector(handleGetAccessibleSourceCommand:)]
+	// [[FBRoute GET:@"/wda/accessibleSource"].withoutSession
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/wda/accessibleSource"); err != nil {
+		return "", err
+	}
+	var jr builtinJSON.RawMessage
+	if jr, err = rawResp.valueConvertToJsonRawMessage(); err != nil {
+		return "", err
+	}
+	source = string(jr)
+	return
+}
+
+func (wd *wdaDriver) HealthCheck() (err error) {
+	// [[FBRoute GET:@"/wda/healthcheck"].withoutSession respondWithTarget:self action:@selector(handleGetHealthCheck:)]
+	_, err = wd.httpGET("/wda/healthcheck")
+	return
+}
+
+func (wd *wdaDriver) GetAppiumSettings() (settings map[string]interface{}, err error) {
+	// [[FBRoute GET:@"/appium/settings"] respondWithTarget:self action:@selector(handleGetSettings:)]
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/session", wd.sessionId, "/appium/settings"); err != nil {
+		return nil, err
+	}
+	reply := new(struct{ Value map[string]interface{} })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+	settings = reply.Value
+	return
+}
+
+func (wd *wdaDriver) SetAppiumSettings(settings map[string]interface{}) (ret map[string]interface{}, err error) {
+	// [[FBRoute POST:@"/appium/settings"] respondWithTarget:self action:@selector(handleSetSettings:)]
+	data := map[string]interface{}{"settings": settings}
+	var rawResp rawResponse
+	if rawResp, err = wd.httpPOST(data, "/session", wd.sessionId, "/appium/settings"); err != nil {
+		return nil, err
+	}
+	reply := new(struct{ Value map[string]interface{} })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return nil, err
+	}
+	ret = reply.Value
+	return
+}
+
+func (wd *wdaDriver) IsHealthy() (healthy bool, err error) {
+	var rawResp rawResponse
+	if rawResp, err = wd.httpGET("/health"); err != nil {
+		return false, err
+	}
+	if string(rawResp) != "I-AM-ALIVE" {
+		return false, nil
+	}
+	return true, nil
+}
+
+func (wd *wdaDriver) WdaShutdown() (err error) {
+	_, err = wd.httpGET("/wda/shutdown")
+	return
+}
+
+func (wd *wdaDriver) WaitWithTimeoutAndInterval(condition Condition, timeout, interval time.Duration) error {
+	startTime := time.Now()
+	for {
+		done, err := condition(wd)
+		if err != nil {
+			return err
+		}
+		if done {
+			return nil
+		}
+
+		if elapsed := time.Since(startTime); elapsed > timeout {
+			return fmt.Errorf("timeout after %v", elapsed)
+		}
+		time.Sleep(interval)
+	}
+}
+
+func (wd *wdaDriver) WaitWithTimeout(condition Condition, timeout time.Duration) error {
+	return wd.WaitWithTimeoutAndInterval(condition, timeout, DefaultWaitInterval)
+}
+
+func (wd *wdaDriver) Wait(condition Condition) error {
+	return wd.WaitWithTimeoutAndInterval(condition, DefaultWaitTimeout, DefaultWaitInterval)
+}
+
+func (wd *wdaDriver) triggerWDALog(data map[string]interface{}) (rawResp []byte, err error) {
+	// [[FBRoute POST:@"/gtf/automation/log"].withoutSession respondWithTarget:self action:@selector(handleAutomationLog:)]
+	return wd.httpPOST(data, "/gtf/automation/log")
+}
+
+func (wd *wdaDriver) StartCaptureLog(identifier ...string) error {
+	log.Info().Msg("start WDA log recording")
+	if identifier == nil {
+		identifier = []string{""}
+	}
+	data := map[string]interface{}{"action": "start", "type": 2, "identifier": identifier[0]}
+	_, err := wd.triggerWDALog(data)
+	if err != nil {
+		return errors.Wrap(code.IOSCaptureLogError,
+			fmt.Sprintf("start WDA log recording failed: %v", err))
+	}
+
+	return nil
+}
+
+type wdaResponse struct {
+	Value     interface{} `json:"value"`
+	SessionID string      `json:"sessionId"`
+}
+
+func (wd *wdaDriver) StopCaptureLog() (result interface{}, err error) {
+	log.Info().Msg("stop log recording")
+	data := map[string]interface{}{"action": "stop"}
+	rawResp, err := wd.triggerWDALog(data)
+	if err != nil {
+		log.Error().Err(err).Bytes("rawResp", rawResp).Msg("failed to get WDA logs")
+		return "", errors.Wrap(code.IOSCaptureLogError,
+			fmt.Sprintf("get WDA logs failed: %v", err))
+	}
+	reply := new(wdaResponse)
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		log.Error().Err(err).Bytes("rawResp", rawResp).Msg("failed to json.Unmarshal WDA logs")
+		return reply, errors.Wrap(code.IOSCaptureLogError,
+			fmt.Sprintf("json.Unmarshal WDA logs failed: %v", err))
+	}
+	log.Info().Interface("value", reply.Value).Msg("get WDA log response")
+	return reply.Value, nil
+}
diff --git a/hrp/pkg/uixt/ios_element.go b/hrp/pkg/uixt/ios_element.go
new file mode 100644
index 000000000..9e2208b5e
--- /dev/null
+++ b/hrp/pkg/uixt/ios_element.go
@@ -0,0 +1,478 @@
+package uixt
+
+import (
+	"bytes"
+	"fmt"
+	"math"
+	"strings"
+
+	"github.com/pkg/errors"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/json"
+)
+
+// All elements returned by search endpoints have assigned element_id.
+// Given element_id you can query properties like:
+// enabled, rect, size, location, text, displayed, accessible, name
+type wdaElement struct {
+	parent *wdaDriver
+	id     string // element_id
+}
+
+func (we wdaElement) Click() (err error) {
+	// [[FBRoute POST:@"/element/:uuid/click"] respondWithTarget:self action:@selector(handleClick:)]
+	_, err = we.parent.httpPOST(nil, "/session", we.parent.sessionId, "/element", we.id, "/click")
+	return
+}
+
+func (we wdaElement) SendKeys(text string, options ...DataOption) (err error) {
+	// [[FBRoute POST:@"/element/:uuid/value"] respondWithTarget:self action:@selector(handleSetValue:)]
+	data := map[string]interface{}{
+		"value": strings.Split(text, ""),
+	}
+	// new data options in post data for extra uiautomator configurations
+	d := NewData(data, options...)
+
+	_, err = we.parent.httpPOST(d.Data, "/session", we.parent.sessionId, "/element", we.id, "/value")
+	return
+}
+
+func (we wdaElement) Clear() (err error) {
+	// [[FBRoute POST:@"/element/:uuid/clear"] respondWithTarget:self action:@selector(handleClear:)]
+	_, err = we.parent.httpPOST(nil, "/session", we.parent.sessionId, "/element", we.id, "/clear")
+	return
+}
+
+func (we wdaElement) Tap(x, y int) error {
+	return we.TapFloat(float64(x), float64(y))
+}
+
+func (we wdaElement) TapFloat(x, y float64) (err error) {
+	// [[FBRoute POST:@"/wda/tap/:uuid"] respondWithTarget:self action:@selector(handleTap:)]
+	data := map[string]interface{}{
+		"x": x,
+		"y": y,
+	}
+	_, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/wda/tap/", we.id)
+	return
+}
+
+func (we wdaElement) DoubleTap() (err error) {
+	// [[FBRoute POST:@"/wda/element/:uuid/doubleTap"] respondWithTarget:self action:@selector(handleDoubleTap:)]
+	_, err = we.parent.httpPOST(nil, "/session", we.parent.sessionId, "/wda/element", we.id, "/doubleTap")
+	return
+}
+
+func (we wdaElement) TouchAndHold(second ...float64) (err error) {
+	// [[FBRoute POST:@"/wda/element/:uuid/touchAndHold"] respondWithTarget:self action:@selector(handleTouchAndHold:)]
+	data := make(map[string]interface{})
+	if len(second) == 0 || second[0] <= 0 {
+		second = []float64{1.0}
+	}
+	data["duration"] = second[0]
+	_, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/wda/element", we.id, "/touchAndHold")
+	return
+}
+
+func (we wdaElement) TwoFingerTap() (err error) {
+	// [[FBRoute POST:@"/wda/element/:uuid/twoFingerTap"] respondWithTarget:self action:@selector(handleTwoFingerTap:)]
+	_, err = we.parent.httpPOST(nil, "/session", we.parent.sessionId, "/wda/element", we.id, "/twoFingerTap")
+	return
+}
+
+func (we wdaElement) TapWithNumberOfTaps(numberOfTaps, numberOfTouches int) (err error) {
+	// [[FBRoute POST:@"/wda/element/:uuid/tapWithNumberOfTaps"] respondWithTarget:self action:@selector(handleTapWithNumberOfTaps:)]
+	if numberOfTouches <= 0 {
+		return errors.New("'numberOfTouches' must be greater than zero")
+	}
+	if numberOfTouches > 5 {
+		return errors.New("'numberOfTouches' cannot be greater than 5")
+	}
+	if numberOfTaps <= 0 {
+		return errors.New("'numberOfTaps' must be greater than zero")
+	}
+	if numberOfTaps > 10 {
+		return errors.New("'numberOfTaps' cannot be greater than 10")
+	}
+	data := map[string]interface{}{
+		"numberOfTaps":    numberOfTaps,
+		"numberOfTouches": numberOfTouches,
+	}
+	_, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/wda/element", we.id, "/tapWithNumberOfTaps")
+	return
+}
+
+func (we wdaElement) ForceTouch(pressure float64, second ...float64) (err error) {
+	return we.ForceTouchFloat(-1, -1, pressure, second...)
+}
+
+func (we wdaElement) ForceTouchFloat(x, y, pressure float64, second ...float64) (err error) {
+	// [[FBRoute POST:@"/wda/element/:uuid/forceTouch"] respondWithTarget:self action:@selector(handleForceTouch:)]
+	data := make(map[string]interface{})
+	if x != -1 && y != -1 {
+		data["x"] = x
+		data["y"] = y
+	}
+	if len(second) == 0 || second[0] <= 0 {
+		second = []float64{1.0}
+	}
+	data["pressure"] = pressure
+	data["duration"] = second[0]
+	_, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/wda/element", we.id, "/forceTouch")
+	return
+}
+
+func (we wdaElement) Drag(fromX, fromY, toX, toY int, pressForDuration ...float64) error {
+	return we.DragFloat(float64(fromX), float64(fromY), float64(toX), float64(toY), pressForDuration...)
+}
+
+func (we wdaElement) DragFloat(fromX, fromY, toX, toY float64, pressForDuration ...float64) (err error) {
+	// [[FBRoute POST:@"/wda/element/:uuid/dragfromtoforduration"] respondWithTarget:self action:@selector(handleDrag:)]
+	data := map[string]interface{}{
+		"fromX": fromX,
+		"fromY": fromY,
+		"toX":   toX,
+		"toY":   toY,
+	}
+	if len(pressForDuration) == 0 || pressForDuration[0] < 0 {
+		pressForDuration = []float64{1.0}
+	}
+	data["duration"] = pressForDuration[0]
+	_, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/wda/element", we.id, "/dragfromtoforduration")
+	return
+}
+
+func (we wdaElement) Swipe(fromX, fromY, toX, toY int) error {
+	return we.SwipeFloat(float64(fromX), float64(fromY), float64(toX), float64(toY))
+}
+
+func (we wdaElement) SwipeFloat(fromX, fromY, toX, toY float64) error {
+	return we.DragFloat(fromX, fromY, toX, toY, 0)
+}
+
+func (we wdaElement) SwipeDirection(direction Direction, velocity ...float64) (err error) {
+	// [[FBRoute POST:@"/wda/element/:uuid/swipe"] respondWithTarget:self action:@selector(handleSwipe:)]
+	data := map[string]interface{}{"direction": direction}
+	if len(velocity) != 0 && velocity[0] > 0 {
+		data["velocity"] = velocity[0]
+	}
+	_, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/wda/element", we.id, "/swipe")
+	return
+}
+
+func (we wdaElement) Pinch(scale, velocity float64) (err error) {
+	// [[FBRoute POST:@"/wda/element/:uuid/pinch"] respondWithTarget:self action:@selector(handlePinch:)]
+	if scale <= 0 {
+		return errors.New("'scale' must be greater than zero")
+	}
+	if scale == 1 {
+		return errors.New("'scale' must be greater or less than 1")
+	}
+	if scale < 1 && velocity > 0 {
+		return errors.New("'velocity' must be less than zero when 'scale' is less than 1")
+	}
+	if scale > 1 && velocity <= 0 {
+		return errors.New("'velocity' must be greater than zero when 'scale' is greater than 1")
+	}
+	data := map[string]interface{}{
+		"scale":    scale,
+		"velocity": velocity,
+	}
+	_, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/wda/element", we.id, "/pinch")
+	return
+}
+
+func (we wdaElement) PinchToZoomOutByW3CAction(scale ...float64) (err error) {
+	if len(scale) == 0 {
+		scale = []float64{1.0}
+	} else if scale[0] > 23 {
+		scale[0] = 23
+	}
+	var size Size
+	if size, err = we.Size(); err != nil {
+		return err
+	}
+	r := scale[0] * 2 / 100.0
+	offsetX, offsetY := float64(size.Width)*r, float64(size.Height)*r
+
+	actions := NewW3CActions().SwipeFloat(0-offsetX, 0-offsetY, 0, 0, we).SwipeFloat(offsetX, offsetY, 0, 0, we)
+	return we.parent.PerformW3CActions(actions)
+}
+
+func (we wdaElement) Rotate(rotation float64, velocity ...float64) (err error) {
+	// [[FBRoute POST:@"/wda/element/:uuid/rotate"] respondWithTarget:self action:@selector(handleRotate:)]
+	if rotation > math.Pi*2 || rotation < math.Pi*-2 {
+		return errors.New("'rotation' must not be more than 2π or less than -2π")
+	}
+	if len(velocity) == 0 || velocity[0] == 0 {
+		velocity = []float64{rotation}
+	}
+	if rotation > 0 && velocity[0] < 0 || rotation < 0 && velocity[0] > 0 {
+		return errors.New("'rotation' and 'velocity' must have the same sign")
+	}
+	data := map[string]interface{}{
+		"rotation": rotation,
+		"velocity": velocity[0],
+	}
+	_, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/wda/element", we.id, "/rotate")
+	return
+}
+
+func (we wdaElement) PickerWheelSelect(order PickerWheelOrder, offset ...int) (err error) {
+	// [[FBRoute POST:@"/wda/pickerwheel/:uuid/select"] respondWithTarget:self action:@selector(handleWheelSelect:)]
+	if len(offset) == 0 {
+		offset = []int{2}
+	} else if offset[0] <= 0 || offset[0] > 5 {
+		return fmt.Errorf("'offset' value is expected to be in range (0, 5]. '%d' was given instead", offset[0])
+	}
+	data := map[string]interface{}{
+		"order":  order,
+		"offset": float64(offset[0]) * 0.1,
+	}
+	_, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/wda/pickerwheel", we.id, "/select")
+	return
+}
+
+func (we wdaElement) scroll(data interface{}) (err error) {
+	// [[FBRoute POST:@"/wda/element/:uuid/scroll"] respondWithTarget:self action:@selector(handleScroll:)]
+	_, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/wda/element", we.id, "/scroll")
+	return
+}
+
+func (we wdaElement) ScrollElementByName(name string) error {
+	data := map[string]interface{}{"name": name}
+	return we.scroll(data)
+}
+
+func (we wdaElement) ScrollElementByPredicate(predicate string) error {
+	data := map[string]interface{}{"predicateString": predicate}
+	return we.scroll(data)
+}
+
+func (we wdaElement) ScrollToVisible() error {
+	data := map[string]interface{}{"toVisible": true}
+	return we.scroll(data)
+}
+
+func (we wdaElement) ScrollDirection(direction Direction, distance ...float64) error {
+	if len(distance) == 0 || distance[0] <= 0 {
+		distance = []float64{0.5}
+	}
+	data := map[string]interface{}{
+		"direction": direction,
+		"distance":  distance[0],
+	}
+	return we.scroll(data)
+}
+
+func (we wdaElement) FindElement(by BySelector) (element WebElement, err error) {
+	// [[FBRoute POST:@"/element/:uuid/element"] respondWithTarget:self action:@selector(handleFindSubElement:)]
+	using, value := by.getUsingAndValue()
+	data := map[string]interface{}{
+		"using": using,
+		"value": value,
+	}
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/element", we.id, "/element"); err != nil {
+		return nil, err
+	}
+	var elementID string
+	if elementID, err = rawResp.valueConvertToElementID(); err != nil {
+		if errors.Is(err, errNoSuchElement) {
+			return nil, fmt.Errorf("%w: unable to find an element using '%s', value '%s'", err, using, value)
+		}
+		return nil, err
+	}
+	element = &wdaElement{parent: we.parent, id: elementID}
+	return
+}
+
+func (we wdaElement) FindElements(by BySelector) (elements []WebElement, err error) {
+	// [[FBRoute POST:@"/element/:uuid/elements"] respondWithTarget:self action:@selector(handleFindSubElements:)]
+	using, value := by.getUsingAndValue()
+	data := map[string]interface{}{
+		"using": using,
+		"value": value,
+	}
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpPOST(data, "/session", we.parent.sessionId, "/element", we.id, "/elements"); err != nil {
+		return nil, err
+	}
+	var elementIDs []string
+	if elementIDs, err = rawResp.valueConvertToElementIDs(); err != nil {
+		if errors.Is(err, errNoSuchElement) {
+			return nil, fmt.Errorf("%w: unable to find an element using '%s', value '%s'", err, using, value)
+		}
+		return nil, err
+	}
+	elements = make([]WebElement, len(elementIDs))
+	for i := range elementIDs {
+		elements[i] = &wdaElement{parent: we.parent, id: elementIDs[i]}
+	}
+	return
+}
+
+func (we wdaElement) FindVisibleCells() (elements []WebElement, err error) {
+	// [[FBRoute GET:@"/wda/element/:uuid/getVisibleCells"] respondWithTarget:self action:@selector(handleFindVisibleCells:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/wda/element", we.id, "/getVisibleCells"); err != nil {
+		return nil, err
+	}
+	var elementIDs []string
+	if elementIDs, err = rawResp.valueConvertToElementIDs(); err != nil {
+		if errors.Is(err, errNoSuchElement) {
+			return nil, fmt.Errorf("%w: unable to find a cell element in this element", err)
+		}
+		return nil, err
+	}
+	elements = make([]WebElement, len(elementIDs))
+	for i := range elementIDs {
+		elements[i] = &wdaElement{parent: we.parent, id: elementIDs[i]}
+	}
+	return
+}
+
+func (we wdaElement) Rect() (rect Rect, err error) {
+	// [[FBRoute GET:@"/element/:uuid/rect"] respondWithTarget:self action:@selector(handleGetRect:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/element", we.id, "/rect"); err != nil {
+		return Rect{}, err
+	}
+	reply := new(struct{ Value struct{ Rect } })
+	if err = json.Unmarshal(rawResp, reply); err != nil {
+		return Rect{}, err
+	}
+	rect = reply.Value.Rect
+	return
+}
+
+func (we wdaElement) Location() (Point, error) {
+	rect, err := we.Rect()
+	if err != nil {
+		return Point{}, err
+	}
+	return rect.Point, nil
+}
+
+func (we wdaElement) Size() (Size, error) {
+	rect, err := we.Rect()
+	if err != nil {
+		return Size{}, err
+	}
+	return rect.Size, nil
+}
+
+func (we wdaElement) Text() (text string, err error) {
+	// [[FBRoute GET:@"/element/:uuid/text"] respondWithTarget:self action:@selector(handleGetText:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/element", we.id, "/text"); err != nil {
+		return "", err
+	}
+	if text, err = rawResp.valueConvertToString(); err != nil {
+		return "", err
+	}
+	return
+}
+
+func (we wdaElement) Type() (elemType string, err error) {
+	// [[FBRoute GET:@"/element/:uuid/name"] respondWithTarget:self action:@selector(handleGetName:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/element", we.id, "/name"); err != nil {
+		return "", err
+	}
+	if elemType, err = rawResp.valueConvertToString(); err != nil {
+		return "", err
+	}
+	return
+}
+
+func (we wdaElement) IsEnabled() (enabled bool, err error) {
+	// [[FBRoute GET:@"/element/:uuid/enabled"] respondWithTarget:self action:@selector(handleGetEnabled:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/element", we.id, "/enabled"); err != nil {
+		return false, err
+	}
+	if enabled, err = rawResp.valueConvertToBool(); err != nil {
+		return false, err
+	}
+	return
+}
+
+func (we wdaElement) IsDisplayed() (displayed bool, err error) {
+	// [[FBRoute GET:@"/element/:uuid/displayed"] respondWithTarget:self action:@selector(handleGetDisplayed:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/element", we.id, "/displayed"); err != nil {
+		return false, err
+	}
+	if displayed, err = rawResp.valueConvertToBool(); err != nil {
+		return false, err
+	}
+	return
+}
+
+func (we wdaElement) IsSelected() (selected bool, err error) {
+	// [[FBRoute GET:@"/element/:uuid/selected"] respondWithTarget:self action:@selector(handleGetSelected:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/element", we.id, "/selected"); err != nil {
+		return false, err
+	}
+	if selected, err = rawResp.valueConvertToBool(); err != nil {
+		return false, err
+	}
+	return
+}
+
+func (we wdaElement) IsAccessible() (accessible bool, err error) {
+	// [[FBRoute GET:@"/wda/element/:uuid/accessible"] respondWithTarget:self action:@selector(handleGetAccessible:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/wda/element", we.id, "/accessible"); err != nil {
+		return false, err
+	}
+	if accessible, err = rawResp.valueConvertToBool(); err != nil {
+		return false, err
+	}
+	return
+}
+
+func (we wdaElement) IsAccessibilityContainer() (isAccessibilityContainer bool, err error) {
+	// [[FBRoute GET:@"/wda/element/:uuid/accessibilityContainer"] respondWithTarget:self action:@selector(handleGetIsAccessibilityContainer:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/wda/element", we.id, "/accessibilityContainer"); err != nil {
+		return false, err
+	}
+	if isAccessibilityContainer, err = rawResp.valueConvertToBool(); err != nil {
+		return false, err
+	}
+	return
+}
+
+func (we wdaElement) GetAttribute(attr ElementAttribute) (value string, err error) {
+	// [[FBRoute GET:@"/element/:uuid/attribute/:name"] respondWithTarget:self action:@selector(handleGetAttribute:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/element", we.id, "/attribute", attr.getAttributeName()); err != nil {
+		return "", err
+	}
+	if value, err = rawResp.valueConvertToString(); err != nil {
+		return "", err
+	}
+	return
+}
+
+func (we wdaElement) UID() (uid string) {
+	return we.id
+}
+
+func (we wdaElement) Screenshot() (raw *bytes.Buffer, err error) {
+	// W3C element screenshot
+	// [[FBRoute GET:@"/element/:uuid/screenshot"] respondWithTarget:self action:@selector(handleElementScreenshot:)]
+	// JSONWP element screenshot
+	// [[FBRoute GET:@"/screenshot/:uuid"] respondWithTarget:self action:@selector(handleElementScreenshot:)]
+	var rawResp rawResponse
+	if rawResp, err = we.parent.httpGET("/session", we.parent.sessionId, "/element", we.id, "/screenshot"); err != nil {
+		return nil, err
+	}
+	if raw, err = rawResp.valueDecodeAsBase64(); err != nil {
+		return nil, err
+	}
+	return
+}
diff --git a/hrp/pkg/uixt/ocr_vedem.go b/hrp/pkg/uixt/ocr_vedem.go
new file mode 100644
index 000000000..1bae01adc
--- /dev/null
+++ b/hrp/pkg/uixt/ocr_vedem.go
@@ -0,0 +1,324 @@
+package uixt
+
+import (
+	"bytes"
+	"fmt"
+	"image"
+	"io/ioutil"
+	"mime/multipart"
+	"net/http"
+	"strings"
+	"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/internal/env"
+	"github.com/httprunner/httprunner/v4/hrp/internal/json"
+)
+
+var client = &http.Client{
+	Timeout: time.Second * 10,
+}
+
+type OCRResult struct {
+	Text   string   `json:"text"`
+	Points []PointF `json:"points"`
+}
+
+type ResponseOCR struct {
+	Code      int         `json:"code"`
+	Message   string      `json:"message"`
+	OCRResult []OCRResult `json:"ocrResult"`
+}
+
+type veDEMOCRService struct{}
+
+func newVEDEMOCRService() (*veDEMOCRService, error) {
+	if err := checkEnv(); err != nil {
+		return nil, err
+	}
+	return &veDEMOCRService{}, nil
+}
+
+func checkEnv() error {
+	if env.VEDEM_OCR_URL == "" {
+		return errors.Wrap(code.OCREnvMissedError, "VEDEM_OCR_URL missed")
+	}
+	if env.VEDEM_OCR_AK == "" {
+		return errors.Wrap(code.OCREnvMissedError, "VEDEM_OCR_AK missed")
+	}
+	if env.VEDEM_OCR_SK == "" {
+		return errors.Wrap(code.OCREnvMissedError, "VEDEM_OCR_SK missed")
+	}
+	return nil
+}
+
+func (s *veDEMOCRService) getOCRResult(imageBuf []byte) ([]OCRResult, error) {
+	bodyBuf := &bytes.Buffer{}
+	bodyWriter := multipart.NewWriter(bodyBuf)
+	bodyWriter.WriteField("withDet", "true")
+	// bodyWriter.WriteField("timestampOnly", "true")
+
+	formWriter, err := bodyWriter.CreateFormFile("image", "screenshot.png")
+	if err != nil {
+		return nil, errors.Wrap(code.OCRRequestError,
+			fmt.Sprintf("create form file error: %v", err))
+	}
+	size, err := formWriter.Write(imageBuf)
+	if err != nil {
+		return nil, errors.Wrap(code.OCRRequestError,
+			fmt.Sprintf("write form error: %v", err))
+	}
+
+	err = bodyWriter.Close()
+	if err != nil {
+		return nil, errors.Wrap(code.OCRRequestError,
+			fmt.Sprintf("close body writer error: %v", err))
+	}
+
+	req, err := http.NewRequest("POST", env.VEDEM_OCR_URL, bodyBuf)
+	if err != nil {
+		return nil, errors.Wrap(code.OCRRequestError,
+			fmt.Sprintf("construct request error: %v", err))
+	}
+
+	token := builtin.Sign("auth-v2", env.VEDEM_OCR_AK, env.VEDEM_OCR_SK, bodyBuf.Bytes())
+	req.Header.Add("Agw-Auth", token)
+	req.Header.Add("Content-Type", bodyWriter.FormDataContentType())
+
+	var resp *http.Response
+	// 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)
+		}
+		log.Error().Err(err).
+			Str("logID", logID).
+			Int("imageBufSize", size).
+			Msgf("request OCR service failed, retry %d", i)
+		time.Sleep(1 * time.Second)
+	}
+	if resp == nil {
+		return nil, code.OCRServiceConnectionError
+	}
+
+	defer resp.Body.Close()
+
+	results, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errors.Wrap(code.OCRResponseError,
+			fmt.Sprintf("read response body error: %v", err))
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, errors.Wrap(code.OCRResponseError,
+			fmt.Sprintf("unexpected response status code: %d, results: %v",
+				resp.StatusCode, string(results)))
+	}
+
+	var ocrResult ResponseOCR
+	err = json.Unmarshal(results, &ocrResult)
+	if err != nil {
+		return nil, errors.Wrap(code.OCRResponseError,
+			fmt.Sprintf("json unmarshal response body error: %v", err))
+	}
+
+	return ocrResult.OCRResult, nil
+}
+
+func getLogID(header http.Header) string {
+	if len(header) == 0 {
+		return ""
+	}
+
+	logID, ok := header["X-Tt-Logid"]
+	if !ok || len(logID) == 0 {
+		return ""
+	}
+	return logID[0]
+}
+
+func (s *veDEMOCRService) FindText(text string, imageBuf []byte, options ...DataOption) (rect image.Rectangle, err error) {
+	data := NewData(map[string]interface{}{}, options...)
+
+	ocrResults, err := s.getOCRResult(imageBuf)
+	if err != nil {
+		log.Error().Err(err).Msg("getOCRResult failed")
+		return
+	}
+
+	var rects []image.Rectangle
+	var ocrTexts []string
+	for _, ocrResult := range ocrResults {
+		rect = image.Rectangle{
+			// ocrResult.Points 顺序：左上 -> 右上 -> 右下 -> 左下
+			Min: image.Point{
+				X: int(ocrResult.Points[0].X),
+				Y: int(ocrResult.Points[0].Y),
+			},
+			Max: image.Point{
+				X: int(ocrResult.Points[2].X),
+				Y: int(ocrResult.Points[2].Y),
+			},
+		}
+		if rect.Min.X > data.Scope[0] && rect.Max.X < data.Scope[2] && rect.Min.Y > data.Scope[1] && rect.Max.Y < data.Scope[3] {
+			ocrTexts = append(ocrTexts, ocrResult.Text)
+
+			// not contains text
+			if !strings.Contains(ocrResult.Text, text) {
+				continue
+			}
+
+			rects = append(rects, rect)
+		}
+
+		// contains text while not match exactly
+		if ocrResult.Text != text {
+			continue
+		}
+
+		// match exactly, and not specify index, return the first one
+		if data.Index == 0 {
+			return rect, nil
+		}
+	}
+
+	if len(rects) == 0 {
+		return image.Rectangle{}, errors.Wrap(code.OCRTextNotFoundError,
+			fmt.Sprintf("text %s not found in %v", text, ocrTexts))
+	}
+
+	// get index
+	idx := data.Index
+	if idx > 0 {
+		// NOTICE: index start from 1
+		idx = idx - 1
+	} else if idx < 0 {
+		idx = len(rects) + idx
+	}
+
+	// index out of range
+	if idx >= len(rects) {
+		return image.Rectangle{}, errors.Wrap(code.OCRTextNotFoundError,
+			fmt.Sprintf("text %s found %d, index %d out of range", text, len(rects), idx))
+	}
+
+	return rects[idx], nil
+}
+
+func (s *veDEMOCRService) FindTexts(texts []string, imageBuf []byte, options ...DataOption) (rects []image.Rectangle, err error) {
+	ocrResults, err := s.getOCRResult(imageBuf)
+	if err != nil {
+		log.Error().Err(err).Msg("getOCRResult failed")
+		return
+	}
+
+	data := NewData(map[string]interface{}{}, options...)
+	ocrTexts := map[string]bool{}
+
+	var success bool
+	var rect image.Rectangle
+	for _, text := range texts {
+		var found bool
+		for _, ocrResult := range ocrResults {
+			rect = image.Rectangle{
+				// ocrResult.Points 顺序：左上 -> 右上 -> 右下 -> 左下
+				Min: image.Point{
+					X: int(ocrResult.Points[0].X),
+					Y: int(ocrResult.Points[0].Y),
+				},
+				Max: image.Point{
+					X: int(ocrResult.Points[2].X),
+					Y: int(ocrResult.Points[2].Y),
+				},
+			}
+
+			if rect.Min.X >= data.Scope[0] && rect.Max.X <= data.Scope[2] && rect.Min.Y >= data.Scope[1] && rect.Max.Y <= data.Scope[3] {
+				ocrTexts[ocrResult.Text] = true
+
+				// not contains text
+				if !strings.Contains(ocrResult.Text, text) {
+					continue
+				}
+
+				found = true
+				rects = append(rects, rect)
+				break
+			}
+		}
+		if !found {
+			rects = append(rects, image.Rectangle{})
+		}
+		success = found || success
+	}
+
+	if !success {
+		return rects, errors.Wrap(code.OCRTextNotFoundError,
+			fmt.Sprintf("texts %s not found in %v", texts, ocrTexts))
+	}
+
+	return rects, nil
+}
+
+type OCRService interface {
+	FindText(text string, imageBuf []byte, index ...int) (rect image.Rectangle, err error)
+}
+
+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)
+		return
+	}
+
+	service, err := newVEDEMOCRService()
+	if err != nil {
+		return
+	}
+	rect, err := service.FindText(ocrText, bufSource.Bytes(), options...)
+	if err != nil {
+		log.Warn().Msgf("FindText failed: %s", err.Error())
+		return
+	}
+
+	log.Info().Str("ocrText", ocrText).
+		Interface("rect", rect).Msgf("FindTextByOCR success")
+	x, y, width, height = dExt.MappingToRectInUIKit(rect)
+	return
+}
+
+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)
+		return
+	}
+
+	service, err := newVEDEMOCRService()
+	if err != nil {
+		return
+	}
+	rects, err := service.FindTexts(ocrTexts, bufSource.Bytes(), options...)
+	if err != nil {
+		log.Warn().Msgf("FindTexts failed: %s", err.Error())
+		return
+	}
+
+	log.Info().Interface("ocrTexts", ocrTexts).
+		Interface("rects", rects).Msgf("FindTextsByOCR success")
+	for _, rect := range rects {
+		x, y, width, height := dExt.MappingToRectInUIKit(rect)
+		points = append(points, []float64{x, y, width, height})
+	}
+
+	return
+}
diff --git a/hrp/pkg/uixt/opencv_off.go b/hrp/pkg/uixt/opencv_off.go
new file mode 100644
index 000000000..ade2c4cd1
--- /dev/null
+++ b/hrp/pkg/uixt/opencv_off.go
@@ -0,0 +1,23 @@
+//go:build !opencv
+
+package uixt
+
+import (
+	"image"
+
+	"github.com/rs/zerolog/log"
+)
+
+func Extend(driver WebDriver, options ...CVOption) (dExt *DriverExt, err error) {
+	return extend(driver)
+}
+
+func (dExt *DriverExt) FindAllImageRect(search string) (rects []image.Rectangle, err error) {
+	log.Fatal().Msg("opencv is not supported")
+	return
+}
+
+func (dExt *DriverExt) FindImageRectInUIKit(imagePath string, options ...DataOption) (x, y, width, height float64, err error) {
+	log.Fatal().Msg("opencv is not supported")
+	return
+}
diff --git a/hrp/pkg/uixt/opencv_on.go b/hrp/pkg/uixt/opencv_on.go
new file mode 100644
index 000000000..e8b3d407d
--- /dev/null
+++ b/hrp/pkg/uixt/opencv_on.go
@@ -0,0 +1,146 @@
+//go:build opencv
+
+package uixt
+
+import (
+	"bytes"
+	"image"
+	"io/ioutil"
+	"os"
+
+	cvHelper "github.com/electricbubble/opencv-helper"
+)
+
+const (
+	// TmCcoeffNormed maps to TM_CCOEFF_NORMED
+	TmCcoeffNormed TemplateMatchMode = iota
+	// TmSqdiff maps to TM_SQDIFF
+	TmSqdiff
+	// TmSqdiffNormed maps to TM_SQDIFF_NORMED
+	TmSqdiffNormed
+	// TmCcorr maps to TM_CCORR
+	TmCcorr
+	// TmCcorrNormed maps to TM_CCORR_NORMED
+	TmCcorrNormed
+	// TmCcoeff maps to TM_CCOEFF
+	TmCcoeff
+)
+
+type DebugMode int
+
+const (
+	// DmOff no output
+	DmOff DebugMode = iota
+	// DmEachMatch output matched and mismatched values
+	DmEachMatch
+	// DmNotMatch output only values that do not match
+	DmNotMatch
+)
+
+// Extend 获得扩展后的 Driver，
+// 并指定匹配阀值，
+// 获取当前设备的 Scale，
+// 默认匹配模式为 TmCcoeffNormed，
+// 默认关闭 OpenCV 匹配值计算后的输出
+func Extend(driver WebDriver, options ...CVOption) (dExt *DriverExt, err error) {
+	dExt, err = extend(driver)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, option := range options {
+		option(&dExt.CVArgs)
+	}
+
+	if dExt.threshold == 0 {
+		dExt.threshold = 0.95 // default threshold
+	}
+	if dExt.matchMode == 0 {
+		dExt.matchMode = TmCcoeffNormed // default match mode
+	}
+	cvHelper.Debug(cvHelper.DebugMode(DmOff))
+	return
+}
+
+func (dExt *DriverExt) Debug(dm DebugMode) {
+	cvHelper.Debug(cvHelper.DebugMode(dm))
+}
+
+func (dExt *DriverExt) OnlyOnceThreshold(threshold float64) (newExt *DriverExt) {
+	newExt = new(DriverExt)
+	newExt.Driver = dExt.Driver
+	newExt.scale = dExt.scale
+	newExt.matchMode = dExt.matchMode
+	newExt.threshold = threshold
+	return
+}
+
+func (dExt *DriverExt) OnlyOnceMatchMode(matchMode TemplateMatchMode) (newExt *DriverExt) {
+	newExt = new(DriverExt)
+	newExt.Driver = dExt.Driver
+	newExt.scale = dExt.scale
+	newExt.matchMode = matchMode
+	newExt.threshold = dExt.threshold
+	return
+}
+
+// func (sExt *DriverExt) findImgRect(search string) (rect image.Rectangle, err error) {
+// 	pathSource := filepath.Join(sExt.pathname, cvHelper.GenFilename())
+// 	if err = sExt.driver.ScreenshotToDisk(pathSource); err != nil {
+// 		return image.Rectangle{}, err
+// 	}
+//
+// 	if rect, err = cvHelper.FindImageRectFromDisk(pathSource, search, float32(sExt.Threshold), cvHelper.TemplateMatchMode(sExt.MatchMode)); err != nil {
+// 		return image.Rectangle{}, err
+// 	}
+// 	return
+// }
+
+func (dExt *DriverExt) FindAllImageRect(search string) (rects []image.Rectangle, err error) {
+	var bufSource, bufSearch *bytes.Buffer
+	if bufSearch, err = getBufFromDisk(search); err != nil {
+		return nil, err
+	}
+	if bufSource, err = dExt.takeScreenShot(); err != nil {
+		return nil, err
+	}
+
+	if rects, err = cvHelper.FindAllImageRectsFromRaw(bufSource, bufSearch, float32(dExt.threshold), cvHelper.TemplateMatchMode(dExt.matchMode)); err != nil {
+		return nil, err
+	}
+	return
+}
+
+func (dExt *DriverExt) FindImageRectInUIKit(imagePath string, options ...DataOption) (x, y, width, height float64, err error) {
+	var bufSource, bufSearch *bytes.Buffer
+	if bufSearch, err = getBufFromDisk(imagePath); err != nil {
+		return 0, 0, 0, 0, err
+	}
+	if bufSource, err = dExt.takeScreenShot(); err != nil {
+		return 0, 0, 0, 0, err
+	}
+
+	var rect image.Rectangle
+	if rect, err = cvHelper.FindImageRectFromRaw(bufSource, bufSearch, float32(dExt.threshold), cvHelper.TemplateMatchMode(dExt.matchMode)); err != nil {
+		return 0, 0, 0, 0, err
+	}
+
+	// if rect, err = dExt.findImgRect(search); err != nil {
+	// 	return 0, 0, 0, 0, err
+	// }
+	x, y, width, height = dExt.MappingToRectInUIKit(rect)
+	return
+}
+
+func getBufFromDisk(name string) (*bytes.Buffer, error) {
+	var f *os.File
+	var err error
+	if f, err = os.Open(name); err != nil {
+		return nil, err
+	}
+	var all []byte
+	if all, err = ioutil.ReadAll(f); err != nil {
+		return nil, err
+	}
+	return bytes.NewBuffer(all), nil
+}
diff --git a/hrp/pkg/uixt/swipe.go b/hrp/pkg/uixt/swipe.go
new file mode 100644
index 000000000..9d0089ff7
--- /dev/null
+++ b/hrp/pkg/uixt/swipe.go
@@ -0,0 +1,170 @@
+package uixt
+
+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"
+)
+
+func assertRelative(p float64) bool {
+	return p >= 0 && p <= 1
+}
+
+// SwipeRelative swipe from relative position [fromX, fromY] to relative position [toX, toY]
+func (dExt *DriverExt) SwipeRelative(fromX, fromY, toX, toY float64, options ...DataOption) error {
+	width := dExt.windowSize.Width
+	height := dExt.windowSize.Height
+
+	if !assertRelative(fromX) || !assertRelative(fromY) ||
+		!assertRelative(toX) || !assertRelative(toY) {
+		return fmt.Errorf("fromX(%f), fromY(%f), toX(%f), toY(%f) must be less than 1",
+			fromX, fromY, toX, toY)
+	}
+
+	fromX = float64(width) * fromX
+	fromY = float64(height) * fromY
+	toX = float64(width) * toX
+	toY = float64(height) * toY
+
+	return dExt.Driver.SwipeFloat(fromX, fromY, toX, toY, options...)
+}
+
+func (dExt *DriverExt) SwipeTo(direction string, options ...DataOption) (err error) {
+	switch direction {
+	case "up":
+		return dExt.SwipeUp(options...)
+	case "down":
+		return dExt.SwipeDown(options...)
+	case "left":
+		return dExt.SwipeLeft(options...)
+	case "right":
+		return dExt.SwipeRight(options...)
+	}
+	return fmt.Errorf("unexpected direction: %s", direction)
+}
+
+func (dExt *DriverExt) SwipeUp(options ...DataOption) (err error) {
+	return dExt.SwipeRelative(0.5, 0.5, 0.5, 0.1, options...)
+}
+
+func (dExt *DriverExt) SwipeDown(options ...DataOption) (err error) {
+	return dExt.SwipeRelative(0.5, 0.5, 0.5, 0.9, options...)
+}
+
+func (dExt *DriverExt) SwipeLeft(options ...DataOption) (err error) {
+	return dExt.SwipeRelative(0.5, 0.5, 0.1, 0.5, options...)
+}
+
+func (dExt *DriverExt) SwipeRight(options ...DataOption) (err error) {
+	return dExt.SwipeRelative(0.5, 0.5, 0.9, 0.5, options...)
+}
+
+type Action func(driver *DriverExt) error
+
+// findCondition indicates the condition to find a UI element
+// foundAction indicates the action to do after a UI element is found
+func (dExt *DriverExt) SwipeUntil(direction interface{}, findCondition Action, foundAction Action, options ...DataOption) error {
+	d := NewData(nil, options...)
+	maxRetryTimes := d.MaxRetryTimes
+	interval := d.Interval
+
+	for i := 0; i < maxRetryTimes; i++ {
+		if err := findCondition(dExt); err == nil {
+			// do action after found
+			return foundAction(dExt)
+		}
+		if d, ok := direction.(string); ok {
+			if err := dExt.SwipeTo(d); err != nil {
+				log.Error().Err(err).Msgf("swipe %s failed", d)
+			}
+		} else if d, ok := direction.([]float64); ok {
+			if err := dExt.SwipeRelative(d[0], d[1], d[2], d[3]); err != nil {
+				log.Error().Err(err).Msgf("swipe %v failed", d)
+			}
+		} else if d, ok := direction.([]interface{}); ok {
+			sx, _ := builtin.Interface2Float64(d[0])
+			sy, _ := builtin.Interface2Float64(d[1])
+			ex, _ := builtin.Interface2Float64(d[2])
+			ey, _ := builtin.Interface2Float64(d[3])
+			if err := dExt.SwipeRelative(sx, sy, ex, ey); err != nil {
+				log.Error().Err(err).Msgf("swipe (%v, %v) to (%v, %v) failed", sx, sy, ex, ey)
+			}
+		}
+		// wait for swipe action to completed and content to load completely
+		time.Sleep(time.Duration(1000*interval) * time.Millisecond)
+	}
+	return errors.Wrap(code.OCRTextNotFoundError,
+		fmt.Sprintf("swipe %s %d times, match condition failed", direction, maxRetryTimes))
+}
+
+func (dExt *DriverExt) LoopUntil(findAction, findCondition, foundAction Action, options ...DataOption) error {
+	d := NewData(nil, options...)
+	maxRetryTimes := d.MaxRetryTimes
+	interval := d.Interval
+
+	for i := 0; i < maxRetryTimes; i++ {
+		if err := findCondition(dExt); err == nil {
+			// do action after found
+			return foundAction(dExt)
+		}
+
+		if err := findAction(dExt); err != nil {
+			log.Error().Err(err).Msgf("find action failed")
+		}
+
+		// wait interval between each findAction
+		time.Sleep(time.Duration(1000*interval) * time.Millisecond)
+	}
+
+	return errors.Wrap(code.OCRTextNotFoundError,
+		fmt.Sprintf("loop %d times, match find condition failed", maxRetryTimes))
+}
+
+func (dExt *DriverExt) swipeToTapApp(appName string, action MobileAction) error {
+	if len(action.Scope) != 4 {
+		action.Scope = []float64{0, 0, 1, 1}
+	}
+
+	identifierOption := WithDataIdentifier(action.Identifier)
+	indexOption := WithDataIndex(action.Index)
+	scopeOption := WithDataScope(dExt.getAbsScope(action.Scope[0], action.Scope[1], action.Scope[2], action.Scope[3]))
+
+	// default to retry 5 times
+	if action.MaxRetryTimes == 0 {
+		action.MaxRetryTimes = 5
+	}
+	maxRetryOption := WithDataMaxRetryTimes(action.MaxRetryTimes)
+	waitTimeOption := WithDataWaitTime(action.WaitTime)
+
+	var point PointF
+	findAppAction := func(d *DriverExt) error {
+		return dExt.SwipeLeft()
+	}
+	findAppCondition := func(d *DriverExt) error {
+		var err error
+		point, err = d.GetTextXY(appName, scopeOption, indexOption)
+		return err
+	}
+	foundAppAction := func(d *DriverExt) error {
+		// click app to launch
+		return d.TapAbsXY(point.X, point.Y-25, identifierOption)
+	}
+
+	// go to home screen
+	if err := dExt.Driver.Homescreen(); err != nil {
+		return errors.Wrap(err, "go to home screen failed")
+	}
+
+	// swipe to first screen
+	for i := 0; i < 5; i++ {
+		dExt.SwipeRight()
+	}
+
+	// swipe next screen until app found
+	return dExt.LoopUntil(findAppAction, findAppCondition, foundAppAction, maxRetryOption, waitTimeOption)
+}
diff --git a/hrp/pkg/uixt/tap.go b/hrp/pkg/uixt/tap.go
new file mode 100644
index 000000000..1629f1379
--- /dev/null
+++ b/hrp/pkg/uixt/tap.go
@@ -0,0 +1,168 @@
+package uixt
+
+import (
+	"fmt"
+)
+
+func (dExt *DriverExt) TapAbsXY(x, y float64, options ...DataOption) error {
+	// tap on absolute coordinate [x, y]
+	return dExt.Driver.TapFloat(x, y, options...)
+}
+
+func (dExt *DriverExt) TapXY(x, y float64, options ...DataOption) error {
+	// tap on [x, y] percent of window size
+	if x > 1 || y > 1 {
+		return fmt.Errorf("x, y percentage should be < 1, got x=%v, y=%v", x, y)
+	}
+
+	x = x * float64(dExt.windowSize.Width)
+	y = y * float64(dExt.windowSize.Height)
+
+	return dExt.TapAbsXY(x, y, options...)
+}
+
+func (dExt *DriverExt) GetTextXY(ocrText string, options ...DataOption) (point PointF, err error) {
+	x, y, width, height, err := dExt.FindTextByOCR(ocrText, options...)
+	if err != nil {
+		return PointF{}, err
+	}
+
+	point = PointF{
+		X: x + width*0.5,
+		Y: y + height*0.5,
+	}
+	return point, nil
+}
+
+func (dExt *DriverExt) GetTextXYs(ocrText []string, options ...DataOption) (points []PointF, err error) {
+	ps, err := dExt.FindTextsByOCR(ocrText, options...)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, point := range ps {
+		pointF := PointF{
+			X: point[0] + point[2]*0.5,
+			Y: point[1] + point[3]*0.5,
+		}
+		points = append(points, pointF)
+	}
+
+	return points, nil
+}
+
+func (dExt *DriverExt) GetImageXY(imagePath string, options ...DataOption) (point PointF, err error) {
+	x, y, width, height, err := dExt.FindImageRectInUIKit(imagePath, options...)
+	if err != nil {
+		return PointF{}, err
+	}
+
+	point = PointF{
+		X: x + width*0.5,
+		Y: y + height*0.5,
+	}
+	return point, nil
+}
+
+func (dExt *DriverExt) TapByOCR(ocrText string, options ...DataOption) error {
+	data := NewData(map[string]interface{}{}, options...)
+
+	point, err := dExt.GetTextXY(ocrText, options...)
+	if err != nil {
+		if data.IgnoreNotFoundError {
+			return nil
+		}
+		return err
+	}
+
+	return dExt.TapAbsXY(point.X, point.Y, options...)
+}
+
+func (dExt *DriverExt) TapByCV(imagePath string, options ...DataOption) error {
+	data := NewData(map[string]interface{}{}, options...)
+
+	point, err := dExt.GetImageXY(imagePath, options...)
+	if err != nil {
+		if data.IgnoreNotFoundError {
+			return nil
+		}
+		return err
+	}
+
+	return dExt.TapAbsXY(point.X, point.Y, options...)
+}
+
+func (dExt *DriverExt) Tap(param string, options ...DataOption) error {
+	return dExt.TapOffset(param, 0.5, 0.5, options...)
+}
+
+func (dExt *DriverExt) TapOffset(param string, xOffset, yOffset float64, options ...DataOption) (err error) {
+	// click on element, find by name attribute
+	ele, err := dExt.FindUIElement(param)
+	if err == nil {
+		return ele.Click()
+	}
+
+	data := NewData(map[string]interface{}{}, options...)
+
+	x, y, width, height, err := dExt.FindUIRectInUIKit(param, options...)
+	if err != nil {
+		if data.IgnoreNotFoundError {
+			return nil
+		}
+		return err
+	}
+
+	return dExt.TapAbsXY(x+width*xOffset, y+height*yOffset, options...)
+}
+
+func (dExt *DriverExt) DoubleTapXY(x, y float64) error {
+	// double tap on coordinate: [x, y] should be relative
+	if x > 1 || y > 1 {
+		return fmt.Errorf("x, y percentage should be < 1, got x=%v, y=%v", x, y)
+	}
+
+	x = x * float64(dExt.windowSize.Width)
+	y = y * float64(dExt.windowSize.Height)
+	return dExt.Driver.DoubleTapFloat(x, y)
+}
+
+func (dExt *DriverExt) DoubleTap(param string) (err error) {
+	return dExt.DoubleTapOffset(param, 0.5, 0.5)
+}
+
+func (dExt *DriverExt) DoubleTapOffset(param string, xOffset, yOffset float64) (err error) {
+	// click on element, find by name attribute
+	ele, err := dExt.FindUIElement(param)
+	if err == nil {
+		return ele.DoubleTap()
+	}
+
+	var x, y, width, height float64
+	if x, y, width, height, err = dExt.FindUIRectInUIKit(param); err != nil {
+		return err
+	}
+
+	return dExt.Driver.DoubleTapFloat(x+width*xOffset, y+height*yOffset)
+}
+
+// TapWithNumber sends one or more taps
+func (dExt *DriverExt) TapWithNumber(param string, numberOfTaps int) (err error) {
+	return dExt.TapWithNumberOffset(param, numberOfTaps, 0.5, 0.5)
+}
+
+func (dExt *DriverExt) TapWithNumberOffset(param string, numberOfTaps int, xOffset, yOffset float64) (err error) {
+	if numberOfTaps <= 0 {
+		numberOfTaps = 1
+	}
+	var x, y, width, height float64
+	if x, y, width, height, err = dExt.FindUIRectInUIKit(param); err != nil {
+		return err
+	}
+
+	x = x + width*xOffset
+	y = y + height*yOffset
+
+	touchActions := NewTouchActions().Tap(NewTouchActionTap().WithXYFloat(x, y).WithCount(numberOfTaps))
+	return dExt.PerformTouchActions(touchActions)
+}
diff --git a/hrp/pkg/uixt/touch.go b/hrp/pkg/uixt/touch.go
new file mode 100644
index 000000000..fe4555073
--- /dev/null
+++ b/hrp/pkg/uixt/touch.go
@@ -0,0 +1,33 @@
+package uixt
+
+func (dExt *DriverExt) ForceTouch(pathname string, pressure float64, duration ...float64) (err error) {
+	return dExt.ForceTouchOffset(pathname, pressure, 0.5, 0.5, duration...)
+}
+
+func (dExt *DriverExt) ForceTouchOffset(pathname string, pressure, xOffset, yOffset float64, duration ...float64) (err error) {
+	if len(duration) == 0 {
+		duration = []float64{1.0}
+	}
+	var x, y, width, height float64
+	if x, y, width, height, err = dExt.FindUIRectInUIKit(pathname); err != nil {
+		return err
+	}
+
+	return dExt.Driver.ForceTouchFloat(x+width*xOffset, y+height*yOffset, pressure, duration[0])
+}
+
+func (dExt *DriverExt) TouchAndHold(pathname string, duration ...float64) (err error) {
+	return dExt.TouchAndHoldOffset(pathname, 0.5, 0.5, duration...)
+}
+
+func (dExt *DriverExt) TouchAndHoldOffset(pathname string, xOffset, yOffset float64, duration ...float64) (err error) {
+	if len(duration) == 0 {
+		duration = []float64{1.0}
+	}
+	var x, y, width, height float64
+	if x, y, width, height, err = dExt.FindUIRectInUIKit(pathname); err != nil {
+		return err
+	}
+
+	return dExt.Driver.TouchAndHoldFloat(x+width*xOffset, y+height*yOffset, duration[0])
+}
diff --git a/hrp/plugin.go b/hrp/plugin.go
index c762b6c89..8e8fc8759 100644
--- a/hrp/plugin.go
+++ b/hrp/plugin.go
@@ -9,9 +9,12 @@ import (
 
 	"github.com/httprunner/funplugin"
 	"github.com/httprunner/funplugin/fungo"
-	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
-	"github.com/httprunner/httprunner/v4/hrp/internal/sdk"
+	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
+	"github.com/httprunner/httprunner/v4/hrp/internal/sdk"
 )
 
 const (
@@ -34,6 +37,7 @@ func initPlugin(path, venv string, logOn bool) (plugin funplugin.IPlugin, err er
 	}
 	pluginPath, err := locatePlugin(path)
 	if err != nil {
+		log.Warn().Err(err).Str("path", path).Msg("locate plugin failed")
 		return nil, nil
 	}
 
@@ -50,14 +54,14 @@ func initPlugin(path, venv string, logOn bool) (plugin funplugin.IPlugin, err er
 		err = BuildPlugin(pluginPath, genPyPluginPath)
 		if err != nil {
 			log.Error().Err(err).Str("path", pluginPath).Msg("build plugin failed")
-			return nil, nil
+			return nil, err
 		}
 		pluginPath = genPyPluginPath
 
 		packages := []string{
 			fmt.Sprintf("funppy==%s", fungo.Version),
 		}
-		python3, err := builtin.EnsurePython3Venv(venv, packages...)
+		python3, err := myexec.EnsurePython3Venv(venv, packages...)
 		if err != nil {
 			log.Error().Err(err).
 				Interface("packages", packages).
@@ -71,6 +75,7 @@ func initPlugin(path, venv string, logOn bool) (plugin funplugin.IPlugin, err er
 	plugin, err = funplugin.Init(pluginPath, pluginOptions...)
 	if err != nil {
 		log.Error().Err(err).Msgf("init plugin failed: %s", pluginPath)
+		err = errors.Wrap(code.InitPluginFailed, err.Error())
 		return
 	}
 
@@ -109,7 +114,6 @@ func locatePlugin(path string) (pluginPath string, err error) {
 		return
 	}
 
-	log.Warn().Err(err).Str("path", path).Msg("plugin file not found")
 	return "", fmt.Errorf("plugin file not found")
 }
 
diff --git a/hrp/response.go b/hrp/response.go
index 42c7e9e1a..5f28b2696 100644
--- a/hrp/response.go
+++ b/hrp/response.go
@@ -16,6 +16,7 @@ import (
 
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
 	"github.com/httprunner/httprunner/v4/hrp/internal/json"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
 )
 
 var fieldTags = []string{"proto", "status_code", "headers", "cookies", "body", textExtractorSubRegexp}
@@ -272,3 +273,38 @@ func (v *responseObject) searchRegexp(expr string) interface{} {
 	log.Error().Str("expr", expr).Msg("search regexp failed")
 	return expr
 }
+
+func validateUI(ud *uixt.DriverExt, iValidators []interface{}) (validateResults []*ValidationResult, err error) {
+	for _, iValidator := range iValidators {
+		validator, ok := iValidator.(Validator)
+		if !ok {
+			return nil, errors.New("validator type error")
+		}
+
+		validataResult := &ValidationResult{
+			Validator:   validator,
+			CheckResult: "fail",
+		}
+
+		// parse check value
+		if !strings.HasPrefix(validator.Check, "ui_") {
+			validataResult.CheckResult = "skip"
+			log.Warn().Interface("validator", validator).Msg("skip validator")
+			validateResults = append(validateResults, validataResult)
+			continue
+		}
+
+		expected, ok := validator.Expect.(string)
+		if !ok {
+			return nil, errors.New("validator expect should be string")
+		}
+
+		if !ud.DoValidation(validator.Check, validator.Assert, expected, validator.Message) {
+			return validateResults, errors.New("step validation failed")
+		}
+
+		validataResult.CheckResult = "pass"
+		validateResults = append(validateResults, validataResult)
+	}
+	return validateResults, nil
+}
diff --git a/hrp/runner.go b/hrp/runner.go
index cff1091fe..63e5b407d 100644
--- a/hrp/runner.go
+++ b/hrp/runner.go
@@ -2,6 +2,7 @@ package hrp
 
 import (
 	"crypto/tls"
+	_ "embed"
 	"net"
 	"net/http"
 	"net/http/cookiejar"
@@ -12,14 +13,16 @@ import (
 	"time"
 
 	"github.com/gorilla/websocket"
+	"github.com/httprunner/funplugin"
 	"github.com/jinzhu/copier"
 	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
 	"golang.org/x/net/http2"
 
-	"github.com/httprunner/funplugin"
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
 	"github.com/httprunner/httprunner/v4/hrp/internal/sdk"
+	"github.com/httprunner/httprunner/v4/hrp/internal/version"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
 )
 
 // Run starts to run API test with default configs.
@@ -49,6 +52,7 @@ func NewRunner(t *testing.T) *HRPRunner {
 			Transport: &http2.Transport{
 				TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
 			},
+			Jar:     jar, // insert response cookies into request
 			Timeout: 120 * time.Second,
 		},
 		// use default handshake timeout (no timeout limit) here, enable timeout at step level
@@ -70,6 +74,7 @@ type HRPRunner struct {
 	httpClient    *http.Client
 	http2Client   *http.Client
 	wsDialer      *websocket.Dialer
+	uiClients     map[string]*uixt.DriverExt // UI automation clients for iOS and Android, key is udid/serial
 }
 
 // SetClientTransport configures transport of http client for high concurrency load testing
@@ -169,6 +174,8 @@ func (r *HRPRunner) GenHTMLReport() *HRPRunner {
 
 // Run starts to execute one or multiple testcases.
 func (r *HRPRunner) Run(testcases ...ITestCase) error {
+	log.Info().Str("hrp_version", version.VERSION).
+		Interface("testcases", testcases).Msg("start running")
 	event := sdk.EventTracking{
 		Category: "RunAPITests",
 		Action:   "hrp run",
@@ -200,19 +207,30 @@ func (r *HRPRunner) Run(testcases ...ITestCase) error {
 	var runErr error
 	// run testcase one by one
 	for _, testcase := range testCases {
-		sessionRunner, err := r.NewSessionRunner(testcase)
+		// each testcase has its own case runner
+		caseRunner, err := r.NewCaseRunner(testcase)
 		if err != nil {
-			log.Error().Err(err).Msg("[Run] init session runner failed")
+			log.Error().Err(err).Msg("[Run] init case runner failed")
 			return err
 		}
 
-		for it := sessionRunner.parametersIterator; it.HasNext(); {
-			err = sessionRunner.Start(it.Next())
-			caseSummary := sessionRunner.GetSummary()
+		// release UI driver session
+		defer func() {
+			for _, client := range r.uiClients {
+				client.Driver.DeleteSession()
+			}
+		}()
+
+		for it := caseRunner.parametersIterator; it.HasNext(); {
+			// case runner can run multiple times with different parameters
+			// each run has its own session runner
+			sessionRunner := caseRunner.NewSession()
+			err1 := sessionRunner.Start(it.Next())
+			caseSummary, err2 := sessionRunner.GetSummary()
 			s.appendCaseSummary(caseSummary)
-			if err != nil {
-				log.Error().Err(err).Msg("[Run] run testcase failed")
-				runErr = err
+			if err1 != nil || err2 != nil {
+				log.Error().Err(err1).Msg("[Run] run testcase failed")
+				runErr = err1
 				break
 			}
 		}
@@ -238,23 +256,10 @@ func (r *HRPRunner) Run(testcases ...ITestCase) error {
 	return runErr
 }
 
-// NewSessionRunner creates a new session runner for testcase.
-// each testcase has its own session runner
-func (r *HRPRunner) NewSessionRunner(testcase *TestCase) (*SessionRunner, error) {
-	runner, err := r.newCaseRunner(testcase)
-	if err != nil {
-		return nil, err
-	}
-
-	sessionRunner := &SessionRunner{
-		testCaseRunner: runner,
-	}
-	sessionRunner.resetSession()
-	return sessionRunner, nil
-}
-
-func (r *HRPRunner) newCaseRunner(testcase *TestCase) (*testCaseRunner, error) {
-	runner := &testCaseRunner{
+// NewCaseRunner creates a new case runner for testcase.
+// each testcase has its own case runner
+func (r *HRPRunner) NewCaseRunner(testcase *TestCase) (*CaseRunner, error) {
+	caseRunner := &CaseRunner{
 		testCase:  testcase,
 		hrpRunner: r,
 		parser:    newParser(),
@@ -266,34 +271,31 @@ func (r *HRPRunner) newCaseRunner(testcase *TestCase) (*testCaseRunner, error) {
 		return nil, errors.Wrap(err, "init plugin failed")
 	}
 	if plugin != nil {
-		runner.parser.plugin = plugin
-		runner.rootDir = filepath.Dir(plugin.Path())
+		caseRunner.parser.plugin = plugin
+		caseRunner.rootDir = filepath.Dir(plugin.Path())
 	}
 
 	// parse testcase config
-	if err := runner.parseConfig(); err != nil {
+	if err := caseRunner.parseConfig(); err != nil {
 		return nil, errors.Wrap(err, "parse testcase config failed")
 	}
 
-	// init websocket params
-	initWebSocket(testcase)
-
 	// set testcase timeout in seconds
-	if runner.testCase.Config.Timeout != 0 {
-		timeout := time.Duration(runner.testCase.Config.Timeout*1000) * time.Millisecond
-		runner.hrpRunner.SetTimeout(timeout)
+	if testcase.Config.Timeout != 0 {
+		timeout := time.Duration(testcase.Config.Timeout*1000) * time.Millisecond
+		r.SetTimeout(timeout)
 	}
 
 	// load plugin info to testcase config
 	if plugin != nil {
 		pluginPath, _ := locatePlugin(testcase.Config.Path)
-		if runner.parsedConfig.PluginSetting == nil {
+		if caseRunner.parsedConfig.PluginSetting == nil {
 			pluginContent, err := builtin.ReadFile(pluginPath)
 			if err != nil {
 				return nil, err
 			}
 			tp := strings.Split(plugin.Path(), ".")
-			runner.parsedConfig.PluginSetting = &PluginConfig{
+			caseRunner.parsedConfig.PluginSetting = &PluginConfig{
 				Path:    pluginPath,
 				Content: pluginContent,
 				Type:    tp[len(tp)-1],
@@ -301,20 +303,21 @@ func (r *HRPRunner) newCaseRunner(testcase *TestCase) (*testCaseRunner, error) {
 		}
 	}
 
-	return runner, nil
+	return caseRunner, nil
 }
 
-type testCaseRunner struct {
-	testCase           *TestCase
-	hrpRunner          *HRPRunner
-	parser             *Parser
+type CaseRunner struct {
+	testCase  *TestCase
+	hrpRunner *HRPRunner
+	parser    *Parser
+
 	parsedConfig       *TConfig
 	parametersIterator *ParametersIterator
 	rootDir            string // project root dir
 }
 
 // parseConfig parses testcase config, stores to parsedConfig.
-func (r *testCaseRunner) parseConfig() error {
+func (r *CaseRunner) parseConfig() error {
 	cfg := r.testCase.Config
 
 	r.parsedConfig = &TConfig{}
@@ -382,15 +385,227 @@ func (r *testCaseRunner) parseConfig() error {
 	}
 	r.parametersIterator = parametersIterator
 
+	// init iOS/Android clients
+	if r.hrpRunner.uiClients == nil {
+		r.hrpRunner.uiClients = make(map[string]*uixt.DriverExt)
+	}
+	for _, iosDeviceConfig := range r.parsedConfig.IOS {
+		if iosDeviceConfig.UDID != "" {
+			udid, err := r.parser.ParseString(iosDeviceConfig.UDID, parsedVariables)
+			if err != nil {
+				return errors.Wrap(err, "failed to parse ios device udid")
+			}
+			iosDeviceConfig.UDID = udid.(string)
+		}
+		device, err := uixt.NewIOSDevice(uixt.GetIOSDeviceOptions(iosDeviceConfig)...)
+		if err != nil {
+			return errors.Wrap(err, "init iOS device failed")
+		}
+		client, err := device.NewDriver(nil)
+		if err != nil {
+			return errors.Wrap(err, "init iOS WDA client failed")
+		}
+		r.hrpRunner.uiClients[device.UDID] = client
+	}
+	for _, androidDeviceConfig := range r.parsedConfig.Android {
+		if androidDeviceConfig.SerialNumber != "" {
+			sn, err := r.parser.ParseString(androidDeviceConfig.SerialNumber, parsedVariables)
+			if err != nil {
+				return errors.Wrap(err, "failed to parse android device serial")
+			}
+			androidDeviceConfig.SerialNumber = sn.(string)
+		}
+		device, err := uixt.NewAndroidDevice(uixt.GetAndroidDeviceOptions(androidDeviceConfig)...)
+		if err != nil {
+			return errors.Wrap(err, "init iOS device failed")
+		}
+		client, err := device.NewDriver(nil)
+		if err != nil {
+			return errors.Wrap(err, "init Android UIAutomator client failed")
+		}
+		r.hrpRunner.uiClients[device.SerialNumber] = client
+	}
+
 	return nil
 }
 
 // each boomer task initiates a new session
 // in order to avoid data racing
-func (r *testCaseRunner) newSession() *SessionRunner {
+func (r *CaseRunner) NewSession() *SessionRunner {
 	sessionRunner := &SessionRunner{
-		testCaseRunner: r,
+		caseRunner: r,
 	}
 	sessionRunner.resetSession()
 	return sessionRunner
 }
+
+// SessionRunner is used to run testcase and its steps.
+// each testcase has its own SessionRunner instance and share session variables.
+type SessionRunner struct {
+	caseRunner       *CaseRunner
+	sessionVariables map[string]interface{}
+	// transactions stores transaction timing info.
+	// key is transaction name, value is map of transaction type and time, e.g. start time and end time.
+	transactions      map[string]map[transactionType]time.Time
+	startTime         time.Time                  // record start time of the testcase
+	summary           *TestCaseSummary           // record test case summary
+	wsConnMap         map[string]*websocket.Conn // save all websocket connections
+	pongResponseChan  chan string                // channel used to receive pong response message
+	closeResponseChan chan *wsCloseRespObject    // channel used to receive close response message
+}
+
+func (r *SessionRunner) resetSession() {
+	log.Info().Msg("reset session runner")
+	r.sessionVariables = make(map[string]interface{})
+	r.transactions = make(map[string]map[transactionType]time.Time)
+	r.startTime = time.Now()
+	r.summary = newSummary()
+	r.wsConnMap = make(map[string]*websocket.Conn)
+	r.pongResponseChan = make(chan string, 1)
+	r.closeResponseChan = make(chan *wsCloseRespObject, 1)
+}
+
+// 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)
+
+	// run step in sequential order
+	for _, step := range r.caseRunner.testCase.TestSteps {
+		// TODO: parse step struct
+		// parse step name
+		parsedName, err := r.caseRunner.parser.ParseString(step.Name(), r.sessionVariables)
+		if err != nil {
+			parsedName = step.Name()
+		}
+		stepName := convertString(parsedName)
+		log.Info().Str("step", stepName).
+			Str("type", string(step.Type())).Msg("run step start")
+
+		// run step
+		stepResult, err := step.Run(r)
+		stepResult.Name = stepName
+
+		// 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
+		}
+
+		// update extracted variables
+		for k, v := range stepResult.ExportVars {
+			r.sessionVariables[k] = v
+		}
+
+		if err == nil {
+			log.Info().Str("step", stepResult.Name).
+				Str("type", string(stepResult.StepType)).
+				Bool("success", true).
+				Interface("exportVars", stepResult.ExportVars).
+				Msg("run step end")
+			continue
+		}
+
+		// failed
+		log.Error().Err(err).Str("step", stepResult.Name).
+			Str("type", string(stepResult.StepType)).
+			Bool("success", false).
+			Msg("run step end")
+
+		// check if failfast
+		if r.caseRunner.hrpRunner.failfast {
+			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
+}
+
+// ParseStepVariables merges step variables with config variables and session variables
+func (r *SessionRunner) ParseStepVariables(stepVariables map[string]interface{}) (map[string]interface{}, error) {
+	// override variables
+	// step variables > session variables (extracted variables from previous steps)
+	overrideVars := mergeVariables(stepVariables, r.sessionVariables)
+	// step variables > testcase config variables
+	overrideVars = mergeVariables(overrideVars, r.caseRunner.parsedConfig.Variables)
+
+	// parse step variables
+	parsedVariables, err := r.caseRunner.parser.ParseVariables(overrideVars)
+	if err != nil {
+		log.Error().Interface("variables", r.caseRunner.parsedConfig.Variables).
+			Err(err).Msg("parse step variables failed")
+		return nil, errors.Wrap(err, "parse step variables failed")
+	}
+	return parsedVariables, nil
+}
+
+// InitWithParameters updates session variables with given parameters.
+// this is used for data driven
+func (r *SessionRunner) InitWithParameters(parameters map[string]interface{}) {
+	if len(parameters) == 0 {
+		return
+	}
+
+	log.Info().Interface("parameters", parameters).Msg("update session variables")
+	for k, v := range parameters {
+		r.sessionVariables[k] = v
+	}
+}
+
+func (r *SessionRunner) GetSummary() (*TestCaseSummary, error) {
+	caseSummary := r.summary
+	caseSummary.Name = r.caseRunner.parsedConfig.Name
+	caseSummary.Time.StartAt = r.startTime
+	caseSummary.Time.Duration = time.Since(r.startTime).Seconds()
+	exportVars := make(map[string]interface{})
+	for _, value := range r.caseRunner.parsedConfig.Export {
+		exportVars[value] = r.sessionVariables[value]
+	}
+	caseSummary.InOut.ExportVars = exportVars
+	caseSummary.InOut.ConfigVars = r.caseRunner.parsedConfig.Variables
+
+	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,
+		}
+
+		// stop performance monitor
+		logs["performance"] = client.GetPerfData()
+
+		caseSummary.Logs = append(caseSummary.Logs, logs)
+	}
+
+	return caseSummary, nil
+}
diff --git a/hrp/server.go b/hrp/server.go
index ceada927e..ab2417a63 100644
--- a/hrp/server.go
+++ b/hrp/server.go
@@ -9,9 +9,10 @@ import (
 	"net/http"
 	"strings"
 
-	"github.com/httprunner/httprunner/v4/hrp/internal/boomer"
-	"github.com/httprunner/httprunner/v4/hrp/internal/json"
 	"github.com/mitchellh/mapstructure"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/json"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/boomer"
 )
 
 const jsonContentType = "application/json; encoding=utf-8"
@@ -134,16 +135,14 @@ type CommonResponseBody struct {
 	ServerStatus
 }
 
-type APIGetWorkersRequestBody struct {
-}
+type APIGetWorkersRequestBody struct{}
 
 type APIGetWorkersResponseBody struct {
 	ServerStatus
 	Data []boomer.WorkerNode `json:"data"`
 }
 
-type APIGetMasterRequestBody struct {
-}
+type APIGetMasterRequestBody struct{}
 
 type APIGetMasterResponseBody struct {
 	ServerStatus
@@ -204,7 +203,7 @@ func (api *apiHandler) Start(w http.ResponseWriter, r *http.Request) {
 		for k := range req.Other {
 			keys = append(keys, k)
 		}
-		err = errors.New(fmt.Sprintf("failed to recognize params: %v", keys))
+		err = fmt.Errorf("failed to recognize params: %v", keys)
 		return
 	}
 
@@ -258,7 +257,7 @@ func (api *apiHandler) ReBalance(w http.ResponseWriter, r *http.Request) {
 		for k := range req.Other {
 			keys = append(keys, k)
 		}
-		err = errors.New(fmt.Sprintf("failed to recognize params: %v", keys))
+		err = fmt.Errorf("failed to recognize params: %v", keys)
 		return
 	}
 
@@ -370,7 +369,7 @@ func (b *HRPBoomer) StartServer(ctx context.Context, addr string) {
 		}
 	}()
 
-	log.Println(fmt.Sprintf("starting HTTP server (%v), please use the API to control master", server.Addr))
+	log.Printf("starting HTTP server (%v), please use the API to control master", server.Addr)
 	err := server.ListenAndServe()
 	if err != nil {
 		if err == http.ErrServerClosed {
diff --git a/hrp/session.go b/hrp/session.go
deleted file mode 100644
index 35192bf4b..000000000
--- a/hrp/session.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package hrp
-
-import (
-	_ "embed"
-	"time"
-
-	"github.com/gorilla/websocket"
-	"github.com/pkg/errors"
-	"github.com/rs/zerolog/log"
-)
-
-// SessionRunner is used to run testcase and its steps.
-// each testcase has its own SessionRunner instance and share session variables.
-type SessionRunner struct {
-	*testCaseRunner
-	sessionVariables map[string]interface{}
-	// transactions stores transaction timing info.
-	// key is transaction name, value is map of transaction type and time, e.g. start time and end time.
-	transactions      map[string]map[transactionType]time.Time
-	startTime         time.Time                  // record start time of the testcase
-	summary           *TestCaseSummary           // record test case summary
-	wsConnMap         map[string]*websocket.Conn // save all websocket connections
-	pongResponseChan  chan string                // channel used to receive pong response message
-	closeResponseChan chan *wsCloseRespObject    // channel used to receive close response message
-}
-
-func (r *SessionRunner) resetSession() {
-	log.Info().Msg("reset session runner")
-	r.sessionVariables = make(map[string]interface{})
-	r.transactions = make(map[string]map[transactionType]time.Time)
-	r.startTime = time.Now()
-	r.summary = newSummary()
-	r.wsConnMap = make(map[string]*websocket.Conn)
-	r.pongResponseChan = make(chan string, 1)
-	r.closeResponseChan = make(chan *wsCloseRespObject, 1)
-}
-
-func (r *SessionRunner) GetParser() *Parser {
-	return r.parser
-}
-
-func (r *SessionRunner) GetConfig() *TConfig {
-	return r.parsedConfig
-}
-
-func (r *SessionRunner) HTTPStatOn() bool {
-	return r.hrpRunner.httpStatOn
-}
-
-func (r *SessionRunner) LogOn() bool {
-	return r.hrpRunner.requestsLogOn
-}
-
-// 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.testCase.Config
-	log.Info().Str("testcase", config.Name).Msg("run testcase start")
-
-	// reset session runner
-	r.resetSession()
-
-	// update config variables with given variables
-	r.updateSessionVariables(givenVars)
-
-	// run step in sequential order
-	for _, step := range r.testCase.TestSteps {
-		// parse step name
-		parsedName, err := r.parser.ParseString(step.Name(), r.sessionVariables)
-		if err != nil {
-			parsedName = step.Name()
-		}
-		stepName := convertString(parsedName)
-		log.Info().Str("step", stepName).
-			Str("type", string(step.Type())).Msg("run step start")
-
-		stepResult, err := step.Run(r)
-		stepResult.Name = stepName
-		if err != nil {
-			log.Error().
-				Str("step", stepResult.Name).
-				Str("type", string(stepResult.StepType)).
-				Bool("success", false).
-				Msg("run step end")
-
-			if r.hrpRunner.failfast {
-				return errors.Wrap(err, "abort running due to failfast setting")
-			}
-		}
-
-		// update extracted variables
-		for k, v := range stepResult.ExportVars {
-			r.sessionVariables[k] = v
-		}
-
-		log.Info().
-			Str("step", stepResult.Name).
-			Str("type", string(stepResult.StepType)).
-			Bool("success", stepResult.Success).
-			Interface("exportVars", stepResult.ExportVars).
-			Msg("run step end")
-	}
-
-	// 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
-}
-
-// MergeStepVariables merges step variables with config variables and session variables
-func (r *SessionRunner) MergeStepVariables(vars map[string]interface{}) (map[string]interface{}, error) {
-	// override variables
-	// step variables > session variables (extracted variables from previous steps)
-	overrideVars := mergeVariables(vars, r.sessionVariables)
-	// step variables > testcase config variables
-	overrideVars = mergeVariables(overrideVars, r.parsedConfig.Variables)
-
-	// parse step variables
-	parsedVariables, err := r.parser.ParseVariables(overrideVars)
-	if err != nil {
-		log.Error().Interface("variables", r.parsedConfig.Variables).
-			Err(err).Msg("parse step variables failed")
-		return nil, err
-	}
-	return parsedVariables, nil
-}
-
-// updateSessionVariables updates session variables with given variables.
-// this is used for data driven
-func (r *SessionRunner) updateSessionVariables(parameters map[string]interface{}) {
-	if len(parameters) == 0 {
-		return
-	}
-
-	log.Info().Interface("parameters", parameters).Msg("update session variables")
-	for k, v := range parameters {
-		r.sessionVariables[k] = v
-	}
-}
-
-func (r *SessionRunner) GetSummary() *TestCaseSummary {
-	caseSummary := r.summary
-	caseSummary.Name = r.parsedConfig.Name
-	caseSummary.Time.StartAt = r.startTime
-	caseSummary.Time.Duration = time.Since(r.startTime).Seconds()
-	exportVars := make(map[string]interface{})
-	for _, value := range r.parsedConfig.Export {
-		exportVars[value] = r.sessionVariables[value]
-	}
-	caseSummary.InOut.ExportVars = exportVars
-	caseSummary.InOut.ConfigVars = r.parsedConfig.Variables
-	return caseSummary
-}
diff --git a/hrp/step.go b/hrp/step.go
index b45838521..b721faeac 100644
--- a/hrp/step.go
+++ b/hrp/step.go
@@ -1,5 +1,11 @@
 package hrp
 
+import (
+	giDevice "github.com/electricbubble/gidevice"
+
+	"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
+)
+
 type StepType string
 
 const (
@@ -10,6 +16,38 @@ const (
 	stepTypeRendezvous  StepType = "rendezvous"
 	stepTypeThinkTime   StepType = "thinktime"
 	stepTypeWebSocket   StepType = "websocket"
+	stepTypeAndroid     StepType = "android"
+	stepTypeIOS         StepType = "ios"
+)
+
+var (
+	WithIdentifier          = uixt.WithIdentifier
+	WithMaxRetryTimes       = uixt.WithMaxRetryTimes
+	WithWaitTime            = uixt.WithWaitTime
+	WithIndex               = uixt.WithIndex
+	WithTimeout             = uixt.WithTimeout
+	WithIgnoreNotFoundError = uixt.WithIgnoreNotFoundError
+	WithText                = uixt.WithText
+	WithID                  = uixt.WithID
+	WithDescription         = uixt.WithDescription
+	WithDirection           = uixt.WithDirection
+	WithCustomDirection     = uixt.WithCustomDirection
+	WithScope               = uixt.WithScope
+)
+
+var (
+	WithPerfSystemCPU         = giDevice.WithPerfSystemCPU
+	WithPerfSystemMem         = giDevice.WithPerfSystemMem
+	WithPerfSystemDisk        = giDevice.WithPerfSystemDisk
+	WithPerfSystemNetwork     = giDevice.WithPerfSystemNetwork
+	WithPerfGPU               = giDevice.WithPerfGPU
+	WithPerfFPS               = giDevice.WithPerfFPS
+	WithPerfNetwork           = giDevice.WithPerfNetwork
+	WithPerfBundleID          = giDevice.WithPerfBundleID
+	WithPerfPID               = giDevice.WithPerfPID
+	WithPerfOutputInterval    = giDevice.WithPerfOutputInterval
+	WithPerfProcessAttributes = giDevice.WithPerfProcessAttributes
+	WithPerfSystemAttributes  = giDevice.WithPerfSystemAttributes
 )
 
 type StepResult struct {
@@ -21,7 +59,7 @@ type StepResult struct {
 	Data        interface{}            `json:"data,omitempty" yaml:"data,omitempty"`               // session data or slice of step data
 	ContentSize int64                  `json:"content_size" yaml:"content_size"`                   // response body length
 	ExportVars  map[string]interface{} `json:"export_vars,omitempty" yaml:"export_vars,omitempty"` // extract variables
-	Attachment  string                 `json:"attachment,omitempty" yaml:"attachment,omitempty"`   // step error information
+	Attachments interface{}            `json:"attachments,omitempty" yaml:"attachments,omitempty"` // store extra step information, such as error message or screenshots
 }
 
 // TStep represents teststep data structure.
@@ -35,6 +73,8 @@ type TStep struct {
 	Rendezvous    *Rendezvous            `json:"rendezvous,omitempty" yaml:"rendezvous,omitempty"`
 	ThinkTime     *ThinkTime             `json:"think_time,omitempty" yaml:"think_time,omitempty"`
 	WebSocket     *WebSocketAction       `json:"websocket,omitempty" yaml:"websocket,omitempty"`
+	Android       *MobileStep            `json:"android,omitempty" yaml:"android,omitempty"`
+	IOS           *MobileStep            `json:"ios,omitempty" yaml:"ios,omitempty"`
 	Variables     map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"`
 	SetupHooks    []string               `json:"setup_hooks,omitempty" yaml:"setup_hooks,omitempty"`
 	TeardownHooks []string               `json:"teardown_hooks,omitempty" yaml:"teardown_hooks,omitempty"`
diff --git a/hrp/step_api.go b/hrp/step_api.go
index f31e96bd2..f66403326 100644
--- a/hrp/step_api.go
+++ b/hrp/step_api.go
@@ -100,9 +100,6 @@ func (s *StepAPIWithOptionalArgs) Struct() *TStep {
 
 func (s *StepAPIWithOptionalArgs) Run(r *SessionRunner) (stepResult *StepResult, err error) {
 	defer func() {
-		if err != nil {
-			r.summary.Success = false
-		}
 		stepResult.StepType = stepTypeAPI
 	}()
 	// extend request with referenced API
diff --git a/hrp/step_mobile_ui.go b/hrp/step_mobile_ui.go
new file mode 100644
index 000000000..3a0cd1433
--- /dev/null
+++ b/hrp/step_mobile_ui.go
@@ -0,0 +1,647 @@
+package hrp
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
+)
+
+// ios setting options
+var (
+	WithUDID                       = uixt.WithUDID
+	WithWDAPort                    = uixt.WithWDAPort
+	WithWDAMjpegPort               = uixt.WithWDAMjpegPort
+	WithLogOn                      = uixt.WithLogOn
+	WithResetHomeOnStartup         = uixt.WithResetHomeOnStartup
+	WithSnapshotMaxDepth           = uixt.WithSnapshotMaxDepth
+	WithAcceptAlertButtonSelector  = uixt.WithAcceptAlertButtonSelector
+	WithDismissAlertButtonSelector = uixt.WithDismissAlertButtonSelector
+	WithPerfOptions                = uixt.WithPerfOptions
+)
+
+// android setting options
+var (
+	WithSerialNumber = uixt.WithSerialNumber
+	WithAdbIP        = uixt.WithAdbIP
+	WithAdbPort      = uixt.WithAdbPort
+	WithAdbLogOn     = uixt.WithAdbLogOn
+)
+
+type MobileStep struct {
+	Serial            string `json:"serial,omitempty" yaml:"serial,omitempty"`
+	uixt.MobileAction `yaml:",inline"`
+	Actions           []uixt.MobileAction `json:"actions,omitempty" yaml:"actions,omitempty"`
+}
+
+// StepMobile implements IStep interface.
+type StepMobile struct {
+	step *TStep
+}
+
+func (s *StepMobile) mobileStep() *MobileStep {
+	if s.step.IOS != nil {
+		return s.step.IOS
+	}
+	return s.step.Android
+}
+
+func (s *StepMobile) Serial(serial string) *StepMobile {
+	s.mobileStep().Serial = serial
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) InstallApp(path string) *StepMobile {
+	s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
+		Method: uixt.AppInstall,
+		Params: path,
+	})
+	return s
+}
+
+func (s *StepMobile) AppLaunch(bundleId string) *StepMobile {
+	s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
+		Method: uixt.AppLaunch,
+		Params: bundleId,
+	})
+	return s
+}
+
+func (s *StepMobile) AppLaunchUnattached(bundleId string) *StepMobile {
+	s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
+		Method: uixt.AppLaunchUnattached,
+		Params: bundleId,
+	})
+	return s
+}
+
+func (s *StepMobile) AppTerminate(bundleId string) *StepMobile {
+	s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
+		Method: uixt.AppTerminate,
+		Params: bundleId,
+	})
+	return s
+}
+
+func (s *StepMobile) Home() *StepMobile {
+	s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
+		Method: uixt.ACTION_Home,
+		Params: nil,
+	})
+	return &StepMobile{step: s.step}
+}
+
+// TapXY taps the point {X,Y}, X & Y is percentage of coordinates
+func (s *StepMobile) TapXY(x, y float64, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_TapXY,
+		Params: []float64{x, y},
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+// TapAbsXY taps the point {X,Y}, X & Y is absolute coordinates
+func (s *StepMobile) TapAbsXY(x, y float64, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_TapAbsXY,
+		Params: []float64{x, y},
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+// Tap taps on the target element
+func (s *StepMobile) Tap(params string, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_Tap,
+		Params: params,
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+// Tap taps on the target element by OCR recognition
+func (s *StepMobile) TapByOCR(ocrText string, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_TapByOCR,
+		Params: ocrText,
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+// Tap taps on the target element by CV recognition
+func (s *StepMobile) TapByCV(imagePath string, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_TapByCV,
+		Params: imagePath,
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+// DoubleTapXY double taps the point {X,Y}, X & Y is percentage of coordinates
+func (s *StepMobile) DoubleTapXY(x, y float64) *StepMobile {
+	s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
+		Method: uixt.ACTION_DoubleTapXY,
+		Params: []float64{x, y},
+	})
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) DoubleTap(params string, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_DoubleTap,
+		Params: params,
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) Swipe(sx, sy, ex, ey float64, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_Swipe,
+		Params: []float64{sx, sy, ex, ey},
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) SwipeUp(options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_Swipe,
+		Params: "up",
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) SwipeDown(options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_Swipe,
+		Params: "down",
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) SwipeLeft(options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_Swipe,
+		Params: "left",
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) SwipeRight(options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_Swipe,
+		Params: "right",
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) SwipeToTapApp(appName string, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_SwipeToTapApp,
+		Params: appName,
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) SwipeToTapText(text string, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_SwipeToTapText,
+		Params: text,
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) SwipeToTapTexts(texts interface{}, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_SwipeToTapTexts,
+		Params: texts,
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) Input(text string, options ...uixt.ActionOption) *StepMobile {
+	action := uixt.MobileAction{
+		Method: uixt.ACTION_Input,
+		Params: text,
+	}
+	for _, option := range options {
+		option(&action)
+	}
+	s.mobileStep().Actions = append(s.mobileStep().Actions, action)
+	return &StepMobile{step: s.step}
+}
+
+// Times specify running times for run last action
+func (s *StepMobile) Times(n int) *StepMobile {
+	if n <= 0 {
+		log.Warn().Int("n", n).Msg("times should be positive, set to 1")
+		n = 1
+	}
+
+	mobileStep := s.mobileStep()
+	actionsTotal := len(mobileStep.Actions)
+	if actionsTotal == 0 {
+		return s
+	}
+
+	// actionsTotal >=1 && n >= 1
+	lastAction := mobileStep.Actions[actionsTotal-1 : actionsTotal][0]
+	for i := 0; i < n-1; i++ {
+		// duplicate last action n-1 times
+		mobileStep.Actions = append(mobileStep.Actions, lastAction)
+	}
+	return &StepMobile{step: s.step}
+}
+
+// Sleep specify sleep seconds after last action
+func (s *StepMobile) Sleep(n float64) *StepMobile {
+	s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
+		Method: uixt.CtlSleep,
+		Params: n,
+	})
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) ScreenShot() *StepMobile {
+	s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
+		Method: uixt.CtlScreenShot,
+		Params: nil,
+	})
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) StartCamera() *StepMobile {
+	s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
+		Method: uixt.CtlStartCamera,
+		Params: nil,
+	})
+	return &StepMobile{step: s.step}
+}
+
+func (s *StepMobile) StopCamera() *StepMobile {
+	s.mobileStep().Actions = append(s.mobileStep().Actions, uixt.MobileAction{
+		Method: uixt.CtlStopCamera,
+		Params: nil,
+	})
+	return &StepMobile{step: s.step}
+}
+
+// Validate switches to step validation.
+func (s *StepMobile) Validate() *StepMobileUIValidation {
+	return &StepMobileUIValidation{
+		step: s.step,
+	}
+}
+
+func (s *StepMobile) Name() string {
+	return s.step.Name
+}
+
+func (s *StepMobile) Type() StepType {
+	return stepTypeIOS
+}
+
+func (s *StepMobile) Struct() *TStep {
+	return s.step
+}
+
+func (s *StepMobile) Run(r *SessionRunner) (*StepResult, error) {
+	return runStepMobileUI(r, s.step)
+}
+
+// StepMobileUIValidation implements IStep interface.
+type StepMobileUIValidation struct {
+	step *TStep
+}
+
+func (s *StepMobileUIValidation) AssertNameExists(expectedName string, msg ...string) *StepMobileUIValidation {
+	v := Validator{
+		Check:  uixt.SelectorName,
+		Assert: uixt.AssertionExists,
+		Expect: expectedName,
+	}
+	if len(msg) > 0 {
+		v.Message = msg[0]
+	} else {
+		v.Message = fmt.Sprintf("attribute name [%s] not found", expectedName)
+	}
+	s.step.Validators = append(s.step.Validators, v)
+	return s
+}
+
+func (s *StepMobileUIValidation) AssertNameNotExists(expectedName string, msg ...string) *StepMobileUIValidation {
+	v := Validator{
+		Check:  uixt.SelectorName,
+		Assert: uixt.AssertionNotExists,
+		Expect: expectedName,
+	}
+	if len(msg) > 0 {
+		v.Message = msg[0]
+	} else {
+		v.Message = fmt.Sprintf("attribute name [%s] should not exist", expectedName)
+	}
+	s.step.Validators = append(s.step.Validators, v)
+	return s
+}
+
+func (s *StepMobileUIValidation) AssertLabelExists(expectedLabel string, msg ...string) *StepMobileUIValidation {
+	v := Validator{
+		Check:  uixt.SelectorLabel,
+		Assert: uixt.AssertionExists,
+		Expect: expectedLabel,
+	}
+	if len(msg) > 0 {
+		v.Message = msg[0]
+	} else {
+		v.Message = fmt.Sprintf("attribute label [%s] not found", expectedLabel)
+	}
+	s.step.Validators = append(s.step.Validators, v)
+	return s
+}
+
+func (s *StepMobileUIValidation) AssertLabelNotExists(expectedLabel string, msg ...string) *StepMobileUIValidation {
+	v := Validator{
+		Check:  uixt.SelectorLabel,
+		Assert: uixt.AssertionNotExists,
+		Expect: expectedLabel,
+	}
+	if len(msg) > 0 {
+		v.Message = msg[0]
+	} else {
+		v.Message = fmt.Sprintf("attribute label [%s] should not exist", expectedLabel)
+	}
+	s.step.Validators = append(s.step.Validators, v)
+	return s
+}
+
+func (s *StepMobileUIValidation) AssertOCRExists(expectedText string, msg ...string) *StepMobileUIValidation {
+	v := Validator{
+		Check:  uixt.SelectorOCR,
+		Assert: uixt.AssertionExists,
+		Expect: expectedText,
+	}
+	if len(msg) > 0 {
+		v.Message = msg[0]
+	} else {
+		v.Message = fmt.Sprintf("ocr text [%s] not found", expectedText)
+	}
+	s.step.Validators = append(s.step.Validators, v)
+	return s
+}
+
+func (s *StepMobileUIValidation) AssertOCRNotExists(expectedText string, msg ...string) *StepMobileUIValidation {
+	v := Validator{
+		Check:  uixt.SelectorOCR,
+		Assert: uixt.AssertionNotExists,
+		Expect: expectedText,
+	}
+	if len(msg) > 0 {
+		v.Message = msg[0]
+	} else {
+		v.Message = fmt.Sprintf("ocr text [%s] should not exist", expectedText)
+	}
+	s.step.Validators = append(s.step.Validators, v)
+	return s
+}
+
+func (s *StepMobileUIValidation) AssertImageExists(expectedImagePath string, msg ...string) *StepMobileUIValidation {
+	v := Validator{
+		Check:  uixt.SelectorImage,
+		Assert: uixt.AssertionExists,
+		Expect: expectedImagePath,
+	}
+	if len(msg) > 0 {
+		v.Message = msg[0]
+	} else {
+		v.Message = fmt.Sprintf("cv image [%s] not found", expectedImagePath)
+	}
+	s.step.Validators = append(s.step.Validators, v)
+	return s
+}
+
+func (s *StepMobileUIValidation) AssertImageNotExists(expectedImagePath string, msg ...string) *StepMobileUIValidation {
+	v := Validator{
+		Check:  uixt.SelectorImage,
+		Assert: uixt.AssertionNotExists,
+		Expect: expectedImagePath,
+	}
+	if len(msg) > 0 {
+		v.Message = msg[0]
+	} else {
+		v.Message = fmt.Sprintf("cv image [%s] should not exist", expectedImagePath)
+	}
+	s.step.Validators = append(s.step.Validators, v)
+	return s
+}
+
+func (s *StepMobileUIValidation) Name() string {
+	return s.step.Name
+}
+
+func (s *StepMobileUIValidation) Type() StepType {
+	return stepTypeIOS
+}
+
+func (s *StepMobileUIValidation) Struct() *TStep {
+	return s.step
+}
+
+func (s *StepMobileUIValidation) Run(r *SessionRunner) (*StepResult, error) {
+	return runStepMobileUI(r, s.step)
+}
+
+func (r *HRPRunner) initUIClient(uuid string, osType string) (client *uixt.DriverExt, err error) {
+	// avoid duplicate init
+	if uuid == "" && len(r.uiClients) > 0 {
+		for _, v := range r.uiClients {
+			return v, nil
+		}
+	}
+
+	// avoid duplicate init
+	if uuid != "" {
+		if client, ok := r.uiClients[uuid]; ok {
+			return client, nil
+		}
+	}
+
+	var device uixt.Device
+	if osType == "ios" {
+		device, err = uixt.NewIOSDevice(uixt.WithUDID(uuid))
+	} else {
+		device, err = uixt.NewAndroidDevice(uixt.WithSerialNumber(uuid))
+	}
+	if err != nil {
+		return nil, errors.Wrapf(err, "init %s device failed", osType)
+	}
+
+	client, err = device.NewDriver(nil)
+	if err != nil {
+		return nil, err
+	}
+
+	// cache wda client
+	if r.uiClients == nil {
+		r.uiClients = make(map[string]*uixt.DriverExt)
+	}
+	r.uiClients[client.UUID] = client
+
+	return client, nil
+}
+
+func runStepMobileUI(s *SessionRunner, step *TStep) (stepResult *StepResult, err error) {
+	stepResult = &StepResult{
+		Name:        step.Name,
+		StepType:    stepTypeIOS,
+		Success:     false,
+		ContentSize: 0,
+	}
+	screenshots := make([]string, 0)
+
+	// merge step variables with session variables
+	stepVariables, err := s.ParseStepVariables(step.Variables)
+	if err != nil {
+		err = errors.Wrap(err, "parse step variables failed")
+		return
+	}
+
+	var osType string
+	var mobileStep *MobileStep
+	if step.IOS != nil {
+		// ios step
+		osType = "ios"
+		mobileStep = step.IOS
+	} else {
+		// android step
+		osType = "android"
+		mobileStep = step.Android
+	}
+
+	// init wda/uia driver
+	uiDriver, err := s.caseRunner.hrpRunner.initUIClient(mobileStep.Serial, osType)
+	if err != nil {
+		return
+	}
+	uiDriver.StartTime = s.startTime
+
+	defer func() {
+		attachments := make(map[string]interface{})
+		if err != nil {
+			attachments["error"] = err.Error()
+		}
+
+		// save attachments
+		screenshots = append(screenshots, uiDriver.ScreenShots...)
+		attachments["screenshots"] = screenshots
+		stepResult.Attachments = attachments
+	}()
+
+	// prepare actions
+	var actions []uixt.MobileAction
+	if mobileStep.Actions == nil {
+		actions = []uixt.MobileAction{
+			{
+				Method: mobileStep.Method,
+				Params: mobileStep.Params,
+			},
+		}
+	} else {
+		actions = mobileStep.Actions
+	}
+
+	// run actions
+	for _, action := range actions {
+		if action.Params, err = s.caseRunner.parser.Parse(action.Params, stepVariables); err != nil {
+			return stepResult, errors.Wrap(code.ParseError,
+				fmt.Sprintf("parse action params failed: %v", err))
+		}
+		if err := uiDriver.DoAction(action); err != nil {
+			if !code.IsErrorPredefined(err) {
+				err = errors.Wrap(code.MobileUIDriverError, err.Error())
+			}
+			return stepResult, err
+		}
+	}
+
+	// take snapshot
+	screenshotPath, err := uiDriver.ScreenShot(
+		fmt.Sprintf("%d_validate_%d", uiDriver.StartTime.Unix(), 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 {
+		if !code.IsErrorPredefined(err) {
+			err = errors.Wrap(code.MobileUIValidationError, err.Error())
+		}
+		return
+	}
+	sessionData := newSessionData()
+	sessionData.Validators = validateResults
+	stepResult.Data = sessionData
+	stepResult.Success = true
+	return stepResult, nil
+}
diff --git a/hrp/step_rendezvous.go b/hrp/step_rendezvous.go
index edd9cf849..a9e5f0e0a 100644
--- a/hrp/step_rendezvous.go
+++ b/hrp/step_rendezvous.go
@@ -44,7 +44,7 @@ func (s *StepRendezvous) Run(r *SessionRunner) (*StepResult, error) {
 	}
 
 	// pass current rendezvous if already released, activate rendezvous sequentially after spawn done
-	if rendezvous.isReleased() || !isPreRendezvousAllReleased(rendezvous, r.testCase.ToTCase()) || !rendezvous.isSpawnDone() {
+	if rendezvous.isReleased() || !isPreRendezvousAllReleased(rendezvous, r.caseRunner.testCase.ToTCase()) || !rendezvous.isSpawnDone() {
 		return stepResult, nil
 	}
 
diff --git a/hrp/step_request.go b/hrp/step_request.go
index 8abd7cdce..4a25d02e1 100644
--- a/hrp/step_request.go
+++ b/hrp/step_request.go
@@ -21,8 +21,9 @@ import (
 	"github.com/rs/zerolog/log"
 
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
-	"github.com/httprunner/httprunner/v4/hrp/internal/httpstat"
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
 	"github.com/httprunner/httprunner/v4/hrp/internal/json"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/httpstat"
 )
 
 type HTTPMethod string
@@ -292,37 +293,28 @@ func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err
 		ContentSize: 0,
 	}
 
+	// merge step variables with session variables
+	stepVariables, err := r.ParseStepVariables(step.Variables)
+	if err != nil {
+		err = errors.Wrap(err, "parse step variables failed")
+		return
+	}
+
 	defer func() {
 		// update testcase summary
 		if err != nil {
-			stepResult.Attachment = err.Error()
-		}
-		// 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
+			stepResult.Attachments = err.Error()
 		}
 	}()
 
-	// override step variables
-	stepVariables, err := r.MergeStepVariables(step.Variables)
-	if err != nil {
-		return
-	}
-
-	err = prepareUpload(r.parser, step, stepVariables)
+	err = prepareUpload(r.caseRunner.parser, step, stepVariables)
 	if err != nil {
 		return
 	}
 
 	sessionData := newSessionData()
-	parser := r.GetParser()
-	config := r.GetConfig()
+	parser := r.caseRunner.parser
+	config := r.caseRunner.parsedConfig
 
 	rb := newRequestBuilder(parser, config, step.Request)
 	rb.req.Method = string(step.Request.Method)
@@ -355,7 +347,7 @@ func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err
 	}
 
 	// log & print request
-	if r.LogOn() {
+	if r.caseRunner.hrpRunner.requestsLogOn {
 		if err := printRequest(rb.req); err != nil {
 			return stepResult, err
 		}
@@ -363,7 +355,7 @@ func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err
 
 	// stat HTTP request
 	var httpStat httpstat.Stat
-	if r.HTTPStatOn() {
+	if r.caseRunner.hrpRunner.httpStatOn {
 		ctx := httpstat.WithHTTPStat(rb.req, &httpStat)
 		rb.req = rb.req.WithContext(ctx)
 	}
@@ -371,9 +363,9 @@ func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err
 	// select HTTP client
 	var client *http.Client
 	if step.Request.HTTP2 {
-		client = r.hrpRunner.http2Client
+		client = r.caseRunner.hrpRunner.http2Client
 	} else {
-		client = r.hrpRunner.httpClient
+		client = r.caseRunner.hrpRunner.httpClient
 	}
 
 	// set step timeout
@@ -399,21 +391,21 @@ func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err
 	defer resp.Body.Close()
 
 	// log & print response
-	if r.LogOn() {
+	if r.caseRunner.hrpRunner.requestsLogOn {
 		if err := printResponse(resp); err != nil {
 			return stepResult, err
 		}
 	}
 
 	// new response object
-	respObj, err := newHttpResponseObject(r.hrpRunner.t, parser, resp)
+	respObj, err := newHttpResponseObject(r.caseRunner.hrpRunner.t, parser, resp)
 	if err != nil {
 		err = errors.Wrap(err, "init ResponseObject error")
 		return
 	}
 
 	stepResult.Elapsed = time.Since(start).Milliseconds()
-	if r.HTTPStatOn() {
+	if r.caseRunner.hrpRunner.httpStatOn {
 		// resp.Body has been ReadAll
 		httpStat.Finish()
 		stepResult.HttpStat = httpStat.Durations()
@@ -692,7 +684,7 @@ func (s *StepRequest) CallRefCase(tc ITestCase) *StepTestCaseWithOptionalArgs {
 	s.step.TestCase, err = tc.ToTestCase()
 	if err != nil {
 		log.Error().Err(err).Msg("failed to load testcase")
-		os.Exit(1)
+		os.Exit(code.GetErrorCode(err))
 	}
 	return &StepTestCaseWithOptionalArgs{
 		step: s.step,
@@ -705,7 +697,7 @@ func (s *StepRequest) CallRefAPI(api IAPI) *StepAPIWithOptionalArgs {
 	s.step.API, err = api.ToAPI()
 	if err != nil {
 		log.Error().Err(err).Msg("failed to load api")
-		os.Exit(1)
+		os.Exit(code.GetErrorCode(err))
 	}
 	return &StepAPIWithOptionalArgs{
 		step: s.step,
@@ -762,6 +754,22 @@ func (s *StepRequest) WebSocket() *StepWebSocket {
 	}
 }
 
+// Android creates a new android action
+func (s *StepRequest) Android() *StepMobile {
+	s.step.Android = &MobileStep{}
+	return &StepMobile{
+		step: s.step,
+	}
+}
+
+// IOS creates a new ios action
+func (s *StepRequest) IOS() *StepMobile {
+	s.step.IOS = &MobileStep{}
+	return &StepMobile{
+		step: s.step,
+	}
+}
+
 // StepRequestWithOptionalArgs implements IStep interface.
 type StepRequestWithOptionalArgs struct {
 	step *TStep
diff --git a/hrp/step_thinktime.go b/hrp/step_thinktime.go
index 6b14b462c..7a505cf80 100644
--- a/hrp/step_thinktime.go
+++ b/hrp/step_thinktime.go
@@ -39,7 +39,7 @@ func (s *StepThinkTime) Run(r *SessionRunner) (*StepResult, error) {
 		Success:  true,
 	}
 
-	cfg := r.parsedConfig.ThinkTimeSetting
+	cfg := r.caseRunner.parsedConfig.ThinkTimeSetting
 	if cfg == nil {
 		cfg = &ThinkTimeConfig{thinkTimeDefault, nil, 0}
 	}
diff --git a/hrp/step_websocket.go b/hrp/step_websocket.go
index 90feec2de..fbb5cfd5f 100644
--- a/hrp/step_websocket.go
+++ b/hrp/step_websocket.go
@@ -231,20 +231,19 @@ type WebSocketAction struct {
 	Timeout         int64                  `json:"timeout,omitempty" yaml:"timeout,omitempty"`
 }
 
-func initWebSocket(testcase *TestCase) {
-	for _, step := range testcase.TestSteps {
-		if step.Struct().WebSocket == nil {
-			continue
-		}
-		// init websocket action parameters
-		if step.Struct().WebSocket.Timeout <= 0 {
-			step.Struct().WebSocket.Timeout = defaultTimeout
-		}
-		// close status code range: [1000, 4999]. ref: https://datatracker.ietf.org/doc/html/rfc6455#section-11.7
-		if step.Struct().WebSocket.CloseStatusCode < 1000 || step.Struct().WebSocket.CloseStatusCode > 4999 {
-			step.Struct().WebSocket.CloseStatusCode = defaultCloseStatus
-		}
+func (w *WebSocketAction) GetTimeout() int64 {
+	if w.Timeout <= 0 {
+		return defaultTimeout
+	}
+	return w.Timeout
+}
+
+func (w *WebSocketAction) GetCloseStatusCode() int64 {
+	// close status code range: [1000, 4999]. ref: https://datatracker.ietf.org/doc/html/rfc6455#section-11.7
+	if w.CloseStatusCode < 1000 || w.CloseStatusCode > 4999 {
+		return defaultCloseStatus
 	}
+	return w.CloseStatusCode
 }
 
 func runStepWebSocket(r *SessionRunner, step *TStep) (stepResult *StepResult, err error) {
@@ -255,32 +254,23 @@ func runStepWebSocket(r *SessionRunner, step *TStep) (stepResult *StepResult, er
 		ContentSize: 0,
 	}
 
+	// merge step variables with session variables
+	stepVariables, err := r.ParseStepVariables(step.Variables)
+	if err != nil {
+		err = errors.Wrap(err, "parse step variables failed")
+		return
+	}
+
 	defer func() {
 		// update testcase summary
 		if err != nil {
-			stepResult.Attachment = err.Error()
-		}
-		// 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
+			stepResult.Attachments = err.Error()
 		}
 	}()
 
-	// override step variables
-	stepVariables, err := r.MergeStepVariables(step.Variables)
-	if err != nil {
-		return
-	}
-
 	sessionData := newSessionData()
-	parser := r.GetParser()
-	config := r.GetConfig()
+	parser := r.caseRunner.parser
+	config := r.caseRunner.parsedConfig
 
 	dummyReq := &Request{
 		URL:     step.WebSocket.URL,
@@ -317,12 +307,12 @@ func runStepWebSocket(r *SessionRunner, step *TStep) (stepResult *StepResult, er
 	start := time.Now()
 
 	// do websocket action
-	if r.LogOn() {
+	if r.caseRunner.hrpRunner.requestsLogOn {
 		fmt.Printf("-------------------- websocket action: %v --------------------\n", step.WebSocket.Type.toString())
 	}
 	switch step.WebSocket.Type {
 	case wsOpen:
-		log.Info().Int64("timeout(ms)", step.WebSocket.Timeout).Str("url", parsedURL).Msg("open websocket connection")
+		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 {
 			break
@@ -332,12 +322,12 @@ func runStepWebSocket(r *SessionRunner, step *TStep) (stepResult *StepResult, er
 			return stepResult, errors.Wrap(err, "open connection failed")
 		}
 	case wsPing:
-		log.Info().Int64("timeout(ms)", step.WebSocket.Timeout).Str("url", parsedURL).Msg("send ping and expect pong")
+		log.Info().Int64("timeout(ms)", step.WebSocket.GetTimeout()).Str("url", parsedURL).Msg("send ping and expect pong")
 		err = writeWebSocket(parsedURL, r, step, stepVariables)
 		if err != nil {
 			return stepResult, errors.Wrap(err, "send ping message failed")
 		}
-		timer := time.NewTimer(time.Duration(step.WebSocket.Timeout) * time.Millisecond)
+		timer := time.NewTimer(time.Duration(step.WebSocket.GetTimeout()) * time.Millisecond)
 		// asynchronous receiving pong message with timeout
 		go func() {
 			select {
@@ -350,7 +340,7 @@ func runStepWebSocket(r *SessionRunner, step *TStep) (stepResult *StepResult, er
 			}
 		}()
 	case wsWriteAndRead:
-		log.Info().Int64("timeout(ms)", step.WebSocket.Timeout).Str("url", parsedURL).Msg("write a message and read response")
+		log.Info().Int64("timeout(ms)", step.WebSocket.GetTimeout()).Str("url", parsedURL).Msg("write a message and read response")
 		err = writeWebSocket(parsedURL, r, step, stepVariables)
 		if err != nil {
 			return stepResult, errors.Wrap(err, "write message failed")
@@ -360,7 +350,7 @@ func runStepWebSocket(r *SessionRunner, step *TStep) (stepResult *StepResult, er
 			return stepResult, errors.Wrap(err, "read message failed")
 		}
 	case wsRead:
-		log.Info().Int64("timeout(ms)", step.WebSocket.Timeout).Str("url", parsedURL).Msg("read only")
+		log.Info().Int64("timeout(ms)", step.WebSocket.GetTimeout()).Str("url", parsedURL).Msg("read only")
 		resp, err = readMessageWithTimeout(parsedURL, r, step)
 		if err != nil {
 			return stepResult, errors.Wrap(err, "read message failed")
@@ -372,7 +362,7 @@ func runStepWebSocket(r *SessionRunner, step *TStep) (stepResult *StepResult, er
 			return stepResult, errors.Wrap(err, "write message failed")
 		}
 	case wsClose:
-		log.Info().Int64("timeout(ms)", step.WebSocket.Timeout).Str("url", parsedURL).Msg("close webSocket connection")
+		log.Info().Int64("timeout(ms)", step.WebSocket.GetTimeout()).Str("url", parsedURL).Msg("close webSocket connection")
 		resp, err = closeWithTimeout(parsedURL, r, step, stepVariables)
 		if err != nil {
 			return stepResult, errors.Wrap(err, "close connection failed")
@@ -380,7 +370,7 @@ func runStepWebSocket(r *SessionRunner, step *TStep) (stepResult *StepResult, er
 	default:
 		return stepResult, errors.Errorf("unexpected websocket frame type: %v", step.WebSocket.Type)
 	}
-	if r.LogOn() {
+	if r.caseRunner.hrpRunner.requestsLogOn {
 		err = printWebSocketResponse(resp)
 		if err != nil {
 			return stepResult, errors.Wrap(err, "print response failed")
@@ -388,7 +378,7 @@ func runStepWebSocket(r *SessionRunner, step *TStep) (stepResult *StepResult, er
 	}
 
 	stepResult.Elapsed = time.Since(start).Milliseconds()
-	respObj, err := getResponseObject(r.hrpRunner.t, r.parser, resp)
+	respObj, err := getResponseObject(r.caseRunner.hrpRunner.t, r.caseRunner.parser, resp)
 	if err != nil {
 		err = errors.Wrap(err, "get response object error")
 		return
@@ -470,7 +460,7 @@ func openWithTimeout(urlStr string, requestHeader http.Header, r *SessionRunner,
 	openResponseChan := make(chan *http.Response)
 	errorChan := make(chan error)
 	go func() {
-		conn, resp, err := r.hrpRunner.wsDialer.Dial(urlStr, requestHeader)
+		conn, resp, err := r.caseRunner.hrpRunner.wsDialer.Dial(urlStr, requestHeader)
 		if err != nil {
 			errorChan <- errors.Wrap(err, "dial tcp failed")
 			return
@@ -496,7 +486,7 @@ func openWithTimeout(urlStr string, requestHeader http.Header, r *SessionRunner,
 		openResponseChan <- resp
 	}()
 
-	timer := time.NewTimer(time.Duration(step.WebSocket.Timeout) * time.Millisecond)
+	timer := time.NewTimer(time.Duration(step.WebSocket.GetTimeout()) * time.Millisecond)
 	select {
 	case <-timer.C:
 		timer.Stop()
@@ -526,7 +516,7 @@ func readMessageWithTimeout(urlString string, r *SessionRunner, step *TStep) (*w
 			}
 		}
 	}()
-	timer := time.NewTimer(time.Duration(step.WebSocket.Timeout) * time.Millisecond)
+	timer := time.NewTimer(time.Duration(step.WebSocket.GetTimeout()) * time.Millisecond)
 	select {
 	case <-timer.C:
 		timer.Stop()
@@ -545,7 +535,7 @@ func writeWebSocket(urlString string, r *SessionRunner, step *TStep, stepVariabl
 	}
 	// check priority: text message > binary message
 	if step.WebSocket.TextMessage != nil {
-		parsedMessage, parseErr := r.parser.Parse(step.WebSocket.TextMessage, stepVariables)
+		parsedMessage, parseErr := r.caseRunner.parser.Parse(step.WebSocket.TextMessage, stepVariables)
 		if parseErr != nil {
 			return parseErr
 		}
@@ -554,7 +544,7 @@ func writeWebSocket(urlString string, r *SessionRunner, step *TStep, stepVariabl
 			return writeErr
 		}
 	} else if step.WebSocket.BinaryMessage != nil {
-		parsedMessage, parseErr := r.parser.Parse(step.WebSocket.BinaryMessage, stepVariables)
+		parsedMessage, parseErr := r.caseRunner.parser.Parse(step.WebSocket.BinaryMessage, stepVariables)
 		if parseErr != nil {
 			return parseErr
 		}
@@ -597,7 +587,7 @@ func writeWithAction(c *websocket.Conn, step *TStep, messageType int, message []
 	case wsPing:
 		return c.WriteControl(websocket.PingMessage, message, time.Now().Add(defaultWriteWait))
 	case wsClose:
-		closeMessage := websocket.FormatCloseMessage(int(step.WebSocket.CloseStatusCode), string(message))
+		closeMessage := websocket.FormatCloseMessage(int(step.WebSocket.GetCloseStatusCode()), string(message))
 		return c.WriteControl(websocket.CloseMessage, closeMessage, time.Now().Add(defaultWriteWait))
 	default:
 		return c.WriteMessage(messageType, message)
@@ -637,7 +627,7 @@ func closeWithTimeout(urlString string, r *SessionRunner, step *TStep, stepVaria
 		// r.wsConn.Close() will be called at the end of current session, so no need to Close here
 		log.Info().Str("msg", readErr.Error()).Msg("connection closed")
 	}()
-	timer := time.NewTimer(time.Duration(step.WebSocket.Timeout) * time.Millisecond)
+	timer := time.NewTimer(time.Duration(step.WebSocket.GetTimeout()) * time.Millisecond)
 	select {
 	case <-timer.C:
 		timer.Stop()
diff --git a/hrp/summary.go b/hrp/summary.go
index b73522cc7..0883a3d8e 100644
--- a/hrp/summary.go
+++ b/hrp/summary.go
@@ -151,7 +151,7 @@ type TestCaseSummary struct {
 	Stat    *TestStepStat  `json:"stat" yaml:"stat"`
 	Time    *TestCaseTime  `json:"time" yaml:"time"`
 	InOut   *TestCaseInOut `json:"in_out" yaml:"in_out"`
-	Log     string         `json:"log,omitempty" yaml:"log,omitempty"` // TODO
+	Logs    []interface{}  `json:"logs,omitempty" yaml:"logs,omitempty"`
 	Records []*StepResult  `json:"records" yaml:"records"`
 	RootDir string         `json:"root_dir" yaml:"root_dir"`
 }
diff --git a/httprunner/__init__.py b/httprunner/__init__.py
index fe4e9b1c2..1f96dea8c 100644
--- a/httprunner/__init__.py
+++ b/httprunner/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "v4.2.0"
+__version__ = "v4.3.0"
 __description__ = "One-stop solution for HTTP(S) testing."
 
 
@@ -19,6 +19,7 @@
     StepThriftRequestValidation,
 )
 
+
 __all__ = [
     "__version__",
     "__description__",
diff --git a/httprunner/database/engine.py b/httprunner/database/engine.py
index aa0c0cece..8a99dedaa 100644
--- a/httprunner/database/engine.py
+++ b/httprunner/database/engine.py
@@ -79,8 +79,8 @@ def update(self, query, commit=True):
 
 
 if __name__ == "__main__":
-    # db = DBEngine(f"mysql+pymysql://xxxxx:xxxxx@10.0.0.1:3306/dbname?charset=utf8mb4")
-    db = DBEngine(f"sqlite:////Users/bytedance/HttpRunner/examples/data/sqlite.db")
+    # db = DBEngine("mysql+pymysql://xxxxx:xxxxx@10.0.0.1:3306/dbname?charset=utf8mb4")
+    db = DBEngine("sqlite:////Users/xxx/HttpRunner/examples/data/sqlite.db")
     print(db.fetchmany("""
     select* from student""", 5))
     print(db.fetchmany("select* from student", 5))
diff --git a/httprunner/utils.py b/httprunner/utils.py
index b95d3ecb7..cbcc8f8e2 100644
--- a/httprunner/utils.py
+++ b/httprunner/utils.py
@@ -243,7 +243,8 @@ def get_index_from_list(lst: List, item: Any):
 
 
 class ExtendJSONEncoder(json.JSONEncoder):
-    """especially used to safely dump json data with python object, such as MultipartEncoder"""
+    """especially used to safely dump json data with python object,
+    such as MultipartEncoder"""
 
     def default(self, obj):
         try:
@@ -275,7 +276,8 @@ def is_support_multiprocessing() -> bool:
         Queue()
         return True
     except (ImportError, OSError):
-        # system that does not support semaphores(dependency of multiprocessing), like Android termux
+        # system that does not support semaphores
+        # (dependency of multiprocessing), like Android termux
         return False
 
 
@@ -320,7 +322,10 @@ def gen_cartesian_product(*args: List[Dict]) -> List[Dict]:
     return product_list
 
 
-LOGGER_FORMAT = "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level}</level> | <level>{message}</level>"
+LOGGER_FORMAT = (
+    "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green>"
+    + " | <level>{level}</level> | <level>{message}</level>"
+)
 
 
 def init_logger(level: str):
diff --git a/pyproject.toml b/pyproject.toml
index 18786b00c..33947c0f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "httprunner"
-version = "v4.2.0"
+version = "v4.3.0"
 description = "One-stop solution for HTTP(S) testing."
 license = "Apache-2.0"
 readme = "README.md"
diff --git a/scripts/build.sh b/scripts/build.sh
index 8043ceb0a..e867f9d39 100644
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -4,8 +4,10 @@
 
 # Usage:
 # $ make build
+# $ make build tags=opencv
 # or
-# $ bash cli/scripts/build.sh
+# $ bash scripts/build.sh
+# $ bash scripts/build.sh opencv
 
 set -e
 set -x
@@ -14,8 +16,15 @@ set -x
 mkdir -p "output"
 bin_path="output/hrp"
 
+# optional build tags: opencv
+tags=$1
+
 # build
-go build -ldflags '-s -w' -o "$bin_path" hrp/cmd/cli/main.go
+if [ -z "$tags" ]; then
+    go build -ldflags '-s -w' -o "$bin_path" hrp/cmd/cli/main.go
+else
+    go build -ldflags '-s -w' -tags "$tags" -o "$bin_path" hrp/cmd/cli/main.go
+fi
 
 # check output and version
 ls -lh "$bin_path"
diff --git a/scripts/bump_version.sh b/scripts/bump_version.sh
index 1abba52d3..981414219 100644
--- a/scripts/bump_version.sh
+++ b/scripts/bump_version.sh
@@ -3,9 +3,9 @@
 # release will be triggered on github actions, see .github/workflows/release.yml
 
 # Usage:
-# $ make bump version=v0.5.2
+# $ make bump version=v4.3.0
 # or
-# $ bash cli/scripts/bump_version.sh v0.5.2
+# $ bash scripts/bump_version.sh v4.3.0
 
 set -e
 


Test patch:
diff --git a/.github/workflows/smoketest.yml b/.github/workflows/smoketest.yml
index f914858a8..efca234ef 100644
--- a/.github/workflows/smoketest.yml
+++ b/.github/workflows/smoketest.yml
@@ -56,7 +56,7 @@ jobs:
       fail-fast: false
       matrix:
         go-version:
-          - 1.17.x
+          - 1.18.x
         os: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.os }}
     steps:
diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml
index 77e221fc5..e8b5d983c 100644
--- a/.github/workflows/unittest.yml
+++ b/.github/workflows/unittest.yml
@@ -63,8 +63,6 @@ jobs:
       fail-fast: false
       matrix:
         go-version:
-          - 1.16.x
-          - 1.17.x
           - 1.18.x
         os: [ubuntu-latest, macos-latest, windows-latest]
     runs-on: ${{ matrix.os }}
diff --git a/docs/cmd/hrp_pytest.md b/docs/cmd/hrp_pytest.md
index b5eced084..3512a8ea1 100644
--- a/docs/cmd/hrp_pytest.md
+++ b/docs/cmd/hrp_pytest.md
@@ -16,4 +16,4 @@ hrp pytest $path ... [flags]
 
 * [hrp](hrp.md)	 - Next-Generation API Testing Solution.
 
-###### Auto generated by spf13/cobra on 21-Aug-2022
+###### Auto generated by spf13/cobra on 21-Oct-2022
diff --git a/examples/uitest/demo_android_douyin_test.go b/examples/uitest/demo_android_douyin_test.go
new file mode 100644
index 000000000..01fa3f949
--- /dev/null
+++ b/examples/uitest/demo_android_douyin_test.go
@@ -0,0 +1,50 @@
+//go:build localtest
+
+package uitest
+
+import (
+	"testing"
+
+	"github.com/httprunner/httprunner/v4/hrp"
+)
+
+func TestAndroidDouYinLive(t *testing.T) {
+	testCase := &hrp.TestCase{
+		Config: hrp.NewConfig("通过 feed 头像进入抖音直播间").
+			SetAndroid(hrp.WithAdbLogOn(true), hrp.WithSerialNumber("2d06bf70")),
+		TestSteps: []hrp.IStep{
+			hrp.NewStep("启动抖音").
+				Android().
+				Home().
+				AppTerminate("com.ss.android.ugc.aweme"). // 关闭已运行的抖音，确保启动抖音后在「抖音」首页
+				SwipeToTapApp("抖音", hrp.WithMaxRetryTimes(5)).
+				Sleep(10),
+			hrp.NewStep("处理青少年弹窗").
+				Android().
+				Tap("推荐").
+				TapByOCR("我知道了", hrp.WithIgnoreNotFoundError(true)).
+				Validate().
+				AssertOCRExists("首页", "抖音启动失败，「首页」不存在"),
+			hrp.NewStep("在推荐页上划，直到出现 feed 头像「直播」").
+				Android().
+				SwipeToTapText("直播", hrp.WithMaxRetryTimes(10), hrp.WithIdentifier("进入直播间")),
+			hrp.NewStep("向上滑动，等待 10s").
+				Android().
+				SwipeUp(hrp.WithIdentifier("第一次上划")).Sleep(10).ScreenShot(). // 上划 1 次，等待 10s，截图保存
+				SwipeUp(hrp.WithIdentifier("第二次上划")).Sleep(10).ScreenShot(), // 再上划 1 次，等待 10s，截图保存
+		},
+	}
+
+	if err := testCase.Dump2JSON("demo_android_douyin_live.json"); err != nil {
+		t.Fatal(err)
+	}
+	if err := testCase.Dump2YAML("demo_android_douyin_live.yaml"); 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_follow_live.json b/examples/uitest/demo_douyin_follow_live.json
new file mode 100644
index 000000000..171ef2683
--- /dev/null
+++ b/examples/uitest/demo_douyin_follow_live.json
@@ -0,0 +1,152 @@
+{
+    "config": {
+        "name": "通过 关注天窗 进入指定主播抖音直播间",
+        "variables": {
+            "app_name": "抖音"
+        },
+        "ios": [
+            {
+                "port": 8700,
+                "mjpeg_port": 8800,
+                "log_on": true
+            }
+        ]
+    },
+    "teststeps": [
+        {
+            "name": "启动抖音",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "home"
+                    },
+                    {
+                        "method": "app_terminate",
+                        "params": "com.ss.iphone.ugc.Aweme"
+                    },
+                    {
+                        "method": "swipe_to_tap_app",
+                        "params": "$app_name",
+                        "identifier": "启动抖音",
+                        "max_retry_times": 5
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 5
+                    }
+                ]
+            },
+            "validate": [
+                {
+                    "check": "ui_ocr",
+                    "assert": "exists",
+                    "expect": "推荐",
+                    "msg": "抖音启动失败，「推荐」不存在"
+                }
+            ]
+        },
+        {
+            "name": "处理青少年弹窗",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "我知道了",
+                        "ignore_NotFoundError": true
+                    }
+                ]
+            }
+        },
+        {
+            "name": "点击首页",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "首页",
+                        "index": -1
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    }
+                ]
+            }
+        },
+        {
+            "name": "点击关注页",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "关注",
+                        "index": 1
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    }
+                ]
+            }
+        },
+        {
+            "name": "向上滑动 2 次",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "swipe_to_tap_texts",
+                        "params": [
+                            "理肤泉",
+                            "婉宝"
+                        ],
+                        "identifier": "click_live",
+                        "direction": [
+                            0.6,
+                            0.2,
+                            0.2,
+                            0.2
+                        ]
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    },
+                    {
+                        "method": "swipe",
+                        "params": [
+                            0.9,
+                            0.7,
+                            0.9,
+                            0.3
+                        ],
+                        "identifier": "slide_in_live"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    },
+                    {
+                        "method": "screenshot"
+                    },
+                    {
+                        "method": "swipe",
+                        "params": [
+                            0.9,
+                            0.7,
+                            0.9,
+                            0.3
+                        ],
+                        "identifier": "slide_in_live"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    },
+                    {
+                        "method": "screenshot"
+                    }
+                ]
+            }
+        }
+    ]
+}
diff --git a/examples/uitest/demo_douyin_follow_live.yaml b/examples/uitest/demo_douyin_follow_live.yaml
new file mode 100644
index 000000000..f6c34c3fc
--- /dev/null
+++ b/examples/uitest/demo_douyin_follow_live.yaml
@@ -0,0 +1,83 @@
+config:
+    name: 通过 关注天窗 进入指定主播抖音直播间
+    variables:
+        app_name: 抖音
+    ios:
+        - port: 8700
+          mjpeg_port: 8800
+          log_on: true
+teststeps:
+    - name: 启动抖音
+      ios:
+        actions:
+            - method: home
+            - method: app_terminate
+              params: com.ss.iphone.ugc.Aweme
+            - method: swipe_to_tap_app
+              params: $app_name
+              identifier: 启动抖音
+              max_retry_times: 5
+            - method: sleep
+              params: 5
+      validate:
+        - check: ui_ocr
+          assert: exists
+          expect: 推荐
+          msg: 抖音启动失败，「推荐」不存在
+    - name: 处理青少年弹窗
+      ios:
+        actions:
+            - method: tap_ocr
+              params: 我知道了
+              ignore_NotFoundError: true
+    - name: 点击首页
+      ios:
+        actions:
+            - method: tap_ocr
+              params: 首页
+              index: -1
+            - method: sleep
+              params: 10
+    - name: 点击关注页
+      ios:
+        actions:
+            - method: tap_ocr
+              params: 关注
+              index: 1
+            - method: sleep
+              params: 10
+    - name: 向上滑动 2 次
+      ios:
+        actions:
+            - method: swipe_to_tap_texts
+              params:
+                - 理肤泉
+                - 婉宝
+              identifier: click_live
+              direction:
+                - 0.6
+                - 0.2
+                - 0.2
+                - 0.2
+            - method: sleep
+              params: 10
+            - method: swipe
+              params:
+                - 0.9
+                - 0.7
+                - 0.9
+                - 0.3
+              identifier: slide_in_live
+            - method: sleep
+              params: 10
+            - method: screenshot
+            - method: swipe
+              params:
+                - 0.9
+                - 0.7
+                - 0.9
+                - 0.3
+              identifier: slide_in_live
+            - method: sleep
+              params: 10
+            - method: screenshot
diff --git a/examples/uitest/demo_douyin_follow_live_test.go b/examples/uitest/demo_douyin_follow_live_test.go
new file mode 100644
index 000000000..1eb42f93f
--- /dev/null
+++ b/examples/uitest/demo_douyin_follow_live_test.go
@@ -0,0 +1,58 @@
+//go:build localtest
+
+package uitest
+
+import (
+	"testing"
+
+	"github.com/httprunner/httprunner/v4/hrp"
+)
+
+func TestIOSDouyinFollowLive(t *testing.T) {
+	testCase := &hrp.TestCase{
+		Config: hrp.NewConfig("通过 关注天窗 进入指定主播抖音直播间").
+			WithVariables(map[string]interface{}{
+				"app_name": "抖音",
+			}).
+			SetIOS(
+				hrp.WithLogOn(true),
+				hrp.WithWDAPort(8700),
+				hrp.WithWDAMjpegPort(8800),
+			),
+		TestSteps: []hrp.IStep{
+			hrp.NewStep("启动抖音").
+				IOS().
+				Home().
+				AppTerminate("com.ss.iphone.ugc.Aweme"). // 关闭已运行的抖音
+				SwipeToTapApp("$app_name", hrp.WithMaxRetryTimes(5), hrp.WithIdentifier("启动抖音")).Sleep(5).
+				Validate().
+				AssertOCRExists("推荐", "抖音启动失败，「推荐」不存在"),
+			hrp.NewStep("处理青少年弹窗").
+				IOS().
+				TapByOCR("我知道了", hrp.WithIgnoreNotFoundError(true)),
+			hrp.NewStep("点击首页").
+				IOS().
+				TapByOCR("首页", hrp.WithIndex(-1)).Sleep(10),
+			hrp.NewStep("点击关注页").
+				IOS().
+				TapByOCR("关注", hrp.WithIndex(1)).Sleep(10),
+			hrp.NewStep("向上滑动 2 次").
+				IOS().SwipeToTapTexts([]string{"理肤泉", "婉宝"}, hrp.WithCustomDirection(0.6, 0.2, 0.2, 0.2), hrp.WithIdentifier("click_live")).Sleep(10).
+				Swipe(0.9, 0.7, 0.9, 0.3, hrp.WithIdentifier("slide_in_live")).Sleep(10).ScreenShot(). // 上划 1 次，等待 10s，截图保存
+				Swipe(0.9, 0.7, 0.9, 0.3, hrp.WithIdentifier("slide_in_live")).Sleep(10).ScreenShot(), // 再上划 1 次，等待 10s，截图保存
+		},
+	}
+
+	if err := testCase.Dump2JSON("demo_douyin_follow_live.json"); err != nil {
+		t.Fatal(err)
+	}
+	if err := testCase.Dump2YAML("demo_douyin_follow_live.yaml"); 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_douyin_live.json
new file mode 100644
index 000000000..7bc7efab4
--- /dev/null
+++ b/examples/uitest/demo_douyin_live.json
@@ -0,0 +1,109 @@
+{
+    "config": {
+        "name": "通过 feed 卡片进入抖音直播间",
+        "variables": {
+            "app_name": "抖音"
+        },
+        "ios": [
+            {
+                "perf_options": {
+                    "sys_cpu": true,
+                    "sys_mem": true
+                },
+                "port": 8700,
+                "mjpeg_port": 8800,
+                "log_on": true
+            }
+        ]
+    },
+    "teststeps": [
+        {
+            "name": "启动抖音",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "home"
+                    },
+                    {
+                        "method": "app_terminate",
+                        "params": "com.ss.iphone.ugc.Aweme"
+                    },
+                    {
+                        "method": "swipe_to_tap_app",
+                        "params": "$app_name",
+                        "identifier": "启动抖音",
+                        "max_retry_times": 5
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 5
+                    }
+                ]
+            },
+            "validate": [
+                {
+                    "check": "ui_ocr",
+                    "assert": "exists",
+                    "expect": "推荐",
+                    "msg": "抖音启动失败，「推荐」不存在"
+                }
+            ]
+        },
+        {
+            "name": "处理青少年弹窗",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "我知道了",
+                        "ignore_NotFoundError": true
+                    }
+                ]
+            }
+        },
+        {
+            "name": "向上滑动 2 次",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "swipe",
+                        "params": "up",
+                        "identifier": "第一次上划"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 2
+                    },
+                    {
+                        "method": "screenshot"
+                    },
+                    {
+                        "method": "swipe",
+                        "params": "up",
+                        "identifier": "第二次上划"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 2
+                    },
+                    {
+                        "method": "screenshot"
+                    }
+                ]
+            }
+        },
+        {
+            "name": "在推荐页上划，直到出现「点击进入直播间」",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "swipe_to_tap_text",
+                        "params": "点击进入直播间",
+                        "identifier": "进入直播间",
+                        "max_retry_times": 10
+                    }
+                ]
+            }
+        }
+    ]
+}
diff --git a/examples/uitest/demo_douyin_live.yaml b/examples/uitest/demo_douyin_live.yaml
new file mode 100644
index 000000000..e20f426c6
--- /dev/null
+++ b/examples/uitest/demo_douyin_live.yaml
@@ -0,0 +1,57 @@
+config:
+    name: 通过 feed 卡片进入抖音直播间
+    variables:
+        app_name: 抖音
+    ios:
+        - perf_options:
+            sys_cpu: true
+            sys_mem: true
+          port: 8700
+          mjpeg_port: 8800
+          log_on: true
+teststeps:
+    - name: 启动抖音
+      ios:
+        actions:
+            - method: home
+            - method: app_terminate
+              params: com.ss.iphone.ugc.Aweme
+            - method: swipe_to_tap_app
+              params: $app_name
+              identifier: 启动抖音
+              max_retry_times: 5
+            - method: sleep
+              params: 5
+      validate:
+        - check: ui_ocr
+          assert: exists
+          expect: 推荐
+          msg: 抖音启动失败，「推荐」不存在
+    - name: 处理青少年弹窗
+      ios:
+        actions:
+            - method: tap_ocr
+              params: 我知道了
+              ignore_NotFoundError: true
+    - name: 向上滑动 2 次
+      ios:
+        actions:
+            - method: swipe
+              params: up
+              identifier: 第一次上划
+            - method: sleep
+              params: 2
+            - method: screenshot
+            - method: swipe
+              params: up
+              identifier: 第二次上划
+            - method: sleep
+              params: 2
+            - method: screenshot
+    - name: 在推荐页上划，直到出现「点击进入直播间」
+      ios:
+        actions:
+            - method: swipe_to_tap_text
+              params: 点击进入直播间
+              identifier: 进入直播间
+              max_retry_times: 10
diff --git a/examples/uitest/demo_douyin_test.go b/examples/uitest/demo_douyin_test.go
new file mode 100644
index 000000000..4d3215bb6
--- /dev/null
+++ b/examples/uitest/demo_douyin_test.go
@@ -0,0 +1,59 @@
+//go:build localtest
+
+package uitest
+
+import (
+	"testing"
+
+	"github.com/httprunner/httprunner/v4/hrp"
+)
+
+func TestIOSDouyinLive(t *testing.T) {
+	testCase := &hrp.TestCase{
+		Config: hrp.NewConfig("通过 feed 卡片进入抖音直播间").
+			WithVariables(map[string]interface{}{
+				"app_name": "抖音",
+			}).
+			SetIOS(
+				hrp.WithLogOn(true),
+				hrp.WithWDAPort(8700),
+				hrp.WithWDAMjpegPort(8800),
+				hrp.WithPerfOptions(
+					hrp.WithPerfSystemCPU(true),
+					hrp.WithPerfSystemMem(true),
+				),
+			),
+		TestSteps: []hrp.IStep{
+			hrp.NewStep("启动抖音").
+				IOS().
+				Home().
+				AppTerminate("com.ss.iphone.ugc.Aweme"). // 关闭已运行的抖音
+				SwipeToTapApp("$app_name", hrp.WithMaxRetryTimes(5), hrp.WithIdentifier("启动抖音")).Sleep(5).
+				Validate().
+				AssertOCRExists("推荐", "抖音启动失败，「推荐」不存在"),
+			hrp.NewStep("处理青少年弹窗").
+				IOS().
+				TapByOCR("我知道了", hrp.WithIgnoreNotFoundError(true)),
+			hrp.NewStep("向上滑动 2 次").
+				IOS().
+				SwipeUp(hrp.WithIdentifier("第一次上划")).Sleep(2).ScreenShot(). // 上划 1 次，等待 2s，截图保存
+				SwipeUp(hrp.WithIdentifier("第二次上划")).Sleep(2).ScreenShot(), // 再上划 1 次，等待 2s，截图保存
+			hrp.NewStep("在推荐页上划，直到出现「点击进入直播间」").
+				IOS().
+				SwipeToTapText("点击进入直播间", hrp.WithMaxRetryTimes(10), hrp.WithIdentifier("进入直播间")),
+		},
+	}
+
+	if err := testCase.Dump2JSON("demo_douyin_live.json"); err != nil {
+		t.Fatal(err)
+	}
+	if err := testCase.Dump2YAML("demo_douyin_live.yaml"); 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_weixin_live.json b/examples/uitest/demo_weixin_live.json
new file mode 100644
index 000000000..618ec7035
--- /dev/null
+++ b/examples/uitest/demo_weixin_live.json
@@ -0,0 +1,114 @@
+{
+    "config": {
+        "name": "通过 feed 卡片进入微信直播间",
+        "ios": [
+            {
+                "port": 8700,
+                "mjpeg_port": 8800,
+                "log_on": true
+            }
+        ]
+    },
+    "teststeps": [
+        {
+            "name": "启动微信",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "home"
+                    },
+                    {
+                        "method": "app_terminate",
+                        "params": "com.tencent.xin"
+                    },
+                    {
+                        "method": "swipe_to_tap_app",
+                        "params": "微信",
+                        "max_retry_times": 5
+                    }
+                ]
+            },
+            "validate": [
+                {
+                    "check": "ui_label",
+                    "assert": "exists",
+                    "expect": "通讯录",
+                    "msg": "微信启动失败，「通讯录」不存在"
+                }
+            ]
+        },
+        {
+            "name": "进入直播页",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap",
+                        "params": "发现"
+                    },
+                    {
+                        "method": "tap_ocr",
+                        "params": "视频号",
+                        "identifier": "进入视频号",
+                        "index": -1
+                    }
+                ]
+            }
+        },
+        {
+            "name": "处理青少年弹窗",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "我知道了",
+                        "ignore_NotFoundError": true
+                    }
+                ]
+            }
+        },
+        {
+            "name": "在推荐页上划，直到出现「轻触进入直播间」",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "swipe_to_tap_text",
+                        "params": "轻触进入直播间",
+                        "identifier": "进入直播间",
+                        "max_retry_times": 10
+                    }
+                ]
+            }
+        },
+        {
+            "name": "向上滑动，等待 10s",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "swipe",
+                        "params": "up",
+                        "identifier": "第一次上划"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    },
+                    {
+                        "method": "screenshot"
+                    },
+                    {
+                        "method": "swipe",
+                        "params": "up",
+                        "identifier": "第二次上划"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    },
+                    {
+                        "method": "screenshot"
+                    }
+                ]
+            }
+        }
+    ]
+}
diff --git a/examples/uitest/demo_weixin_live.yaml b/examples/uitest/demo_weixin_live.yaml
new file mode 100644
index 000000000..3b064f09f
--- /dev/null
+++ b/examples/uitest/demo_weixin_live.yaml
@@ -0,0 +1,58 @@
+config:
+    name: 通过 feed 卡片进入微信直播间
+    ios:
+        - port: 8700
+          mjpeg_port: 8800
+          log_on: true
+teststeps:
+    - name: 启动微信
+      ios:
+        actions:
+            - method: home
+            - method: app_terminate
+              params: com.tencent.xin
+            - method: swipe_to_tap_app
+              params: 微信
+              max_retry_times: 5
+      validate:
+        - check: ui_label
+          assert: exists
+          expect: 通讯录
+          msg: 微信启动失败，「通讯录」不存在
+    - name: 进入直播页
+      ios:
+        actions:
+            - method: tap
+              params: 发现
+            - method: tap_ocr
+              params: 视频号
+              identifier: 进入视频号
+              index: -1
+    - name: 处理青少年弹窗
+      ios:
+        actions:
+            - method: tap_ocr
+              params: 我知道了
+              ignore_NotFoundError: true
+    - name: 在推荐页上划，直到出现「轻触进入直播间」
+      ios:
+        actions:
+            - method: swipe_to_tap_text
+              params: 轻触进入直播间
+              identifier: 进入直播间
+              max_retry_times: 10
+    - name: 向上滑动，等待 10s
+      ios:
+        actions:
+            - method: swipe
+              params: up
+              identifier: 第一次上划
+            - method: sleep
+              params: 10
+            - method: screenshot
+            - method: swipe
+              params: up
+              identifier: 第二次上划
+            - method: sleep
+              params: 10
+            - method: screenshot
diff --git a/examples/uitest/demo_weixin_test.go b/examples/uitest/demo_weixin_test.go
new file mode 100644
index 000000000..835411c5d
--- /dev/null
+++ b/examples/uitest/demo_weixin_test.go
@@ -0,0 +1,52 @@
+//go:build localtest
+
+package uitest
+
+import (
+	"testing"
+
+	"github.com/httprunner/httprunner/v4/hrp"
+)
+
+func TestIOSWeixinLive(t *testing.T) {
+	testCase := &hrp.TestCase{
+		Config: hrp.NewConfig("通过 feed 卡片进入微信直播间").
+			SetIOS(hrp.WithLogOn(true), hrp.WithWDAPort(8700), hrp.WithWDAMjpegPort(8800)),
+		TestSteps: []hrp.IStep{
+			hrp.NewStep("启动微信").
+				IOS().
+				Home().
+				AppTerminate("com.tencent.xin"). // 关闭已运行的微信，确保启动微信后在「微信」首页
+				SwipeToTapApp("微信", hrp.WithMaxRetryTimes(5)).
+				Validate().
+				AssertLabelExists("通讯录", "微信启动失败，「通讯录」不存在"),
+			hrp.NewStep("进入直播页").
+				IOS().
+				Tap("发现").                                                       // 进入「发现页」
+				TapByOCR("视频号", hrp.WithIdentifier("进入视频号"), hrp.WithIndex(-1)), // 通过 OCR 识别「视频号」
+			hrp.NewStep("处理青少年弹窗").
+				IOS().
+				TapByOCR("我知道了", hrp.WithIgnoreNotFoundError(true)),
+			hrp.NewStep("在推荐页上划，直到出现「轻触进入直播间」").
+				IOS().
+				SwipeToTapText("轻触进入直播间", hrp.WithMaxRetryTimes(10), hrp.WithIdentifier("进入直播间")),
+			hrp.NewStep("向上滑动，等待 10s").
+				IOS().
+				SwipeUp(hrp.WithIdentifier("第一次上划")).Sleep(10).ScreenShot(). // 上划 1 次，等待 10s，截图保存
+				SwipeUp(hrp.WithIdentifier("第二次上划")).Sleep(10).ScreenShot(), // 再上划 1 次，等待 10s，截图保存
+		},
+	}
+
+	if err := testCase.Dump2JSON("demo_weixin_live.json"); err != nil {
+		t.Fatal(err)
+	}
+	if err := testCase.Dump2YAML("demo_weixin_live.yaml"); 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/ios_kuaishou_follow_live_test.go b/examples/uitest/ios_kuaishou_follow_live_test.go
new file mode 100644
index 000000000..e16b5be17
--- /dev/null
+++ b/examples/uitest/ios_kuaishou_follow_live_test.go
@@ -0,0 +1,60 @@
+//go:build localtest
+
+package uitest
+
+import (
+	"testing"
+
+	"github.com/httprunner/httprunner/v4/hrp"
+)
+
+func TestIOSKuaiShouLive(t *testing.T) {
+	testCase := &hrp.TestCase{
+		Config: hrp.NewConfig("直播_快手_关注天窗_ios").
+			WithVariables(map[string]interface{}{
+				"device": "${ENV(UDID)}",
+				"ups":    "${ENV(LIVEUPLIST)}",
+			}).
+			SetIOS(hrp.WithUDID("$device"), hrp.WithLogOn(true), hrp.WithWDAPort(8100), hrp.WithWDAMjpegPort(9100)),
+		TestSteps: []hrp.IStep{
+			hrp.NewStep("启动快手").
+				IOS().
+				AppTerminate("com.jiangjia.gif").
+				AppLaunch("com.jiangjia.gif").
+				Home().
+				SwipeToTapApp("快手", hrp.WithMaxRetryTimes(5)).Sleep(10).
+				Validate().
+				AssertOCRExists("精选", "进入快手失败"),
+			hrp.NewStep("点击首页").
+				IOS().
+				TapByOCR("首页", hrp.WithIndex(-1)).Sleep(10),
+			hrp.NewStep("点击发现页").
+				IOS().
+				TapByOCR("发现", hrp.WithIndex(1)).Sleep(10),
+			hrp.NewStep("点击关注页").
+				IOS().
+				TapByOCR("关注", hrp.WithIndex(1)).Sleep(10),
+			hrp.NewStep("点击直播标签,进入直播间").
+				IOS().
+				SwipeToTapTexts("${split_by_comma($ups)}", hrp.WithCustomDirection(0.6, 0.2, 0.2, 0.2), hrp.WithIdentifier("click_live")).Sleep(60).
+				Validate().
+				AssertOCRExists("说点什么", "进入直播间失败"),
+			hrp.NewStep("下滑进入下一个直播间").
+				IOS().
+				Swipe(0.9, 0.7, 0.9, 0.3, hrp.WithIdentifier("slide_in_live")).Sleep(60),
+		},
+	}
+
+	if err := testCase.Dump2JSON("ios_kuaishou_follow_live_test.json"); err != nil {
+		t.Fatal(err)
+	}
+	if err := testCase.Dump2YAML("ios_kuaishou_follow_live_test.yaml"); 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/ios_kuaishou_follow_live_test.json b/examples/uitest/ios_kuaishou_follow_live_test.json
new file mode 100644
index 000000000..3817b1672
--- /dev/null
+++ b/examples/uitest/ios_kuaishou_follow_live_test.json
@@ -0,0 +1,153 @@
+{
+    "config": {
+        "name": "直播_快手_关注天窗_ios",
+        "variables": {
+            "device": "${ENV(UDID)}",
+            "ups": "${ENV(LIVEUPLIST)}"
+        },
+        "ios": [
+            {
+                "udid": "$device",
+                "port": 8100,
+                "mjpeg_port": 9100,
+                "log_on": true
+            }
+        ]
+    },
+    "teststeps": [
+        {
+            "name": "启动快手",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "app_terminate",
+                        "params": "com.jiangjia.gif"
+                    },
+                    {
+                        "method": "app_launch",
+                        "params": "com.jiangjia.gif"
+                    },
+                    {
+                        "method": "home"
+                    },
+                    {
+                        "method": "swipe_to_tap_app",
+                        "params": "快手",
+                        "max_retry_times": 5
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    }
+                ]
+            },
+            "validate": [
+                {
+                    "check": "ui_ocr",
+                    "assert": "exists",
+                    "expect": "精选",
+                    "msg": "进入快手失败"
+                }
+            ]
+        },
+        {
+            "name": "点击首页",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "首页",
+                        "index": -1
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    }
+                ]
+            }
+        },
+        {
+            "name": "点击发现页",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "发现",
+                        "index": 1
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    }
+                ]
+            }
+        },
+        {
+            "name": "点击关注页",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "关注",
+                        "index": 1
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 10
+                    }
+                ]
+            }
+        },
+        {
+            "name": "点击直播标签,进入直播间",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "swipe_to_tap_texts",
+                        "params": "${split_by_comma($ups)}",
+                        "identifier": "click_live",
+                        "direction": [
+                            0.6,
+                            0.2,
+                            0.2,
+                            0.2
+                        ]
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 60
+                    }
+                ]
+            },
+            "validate": [
+                {
+                    "check": "ui_ocr",
+                    "assert": "exists",
+                    "expect": "说点什么",
+                    "msg": "进入直播间失败"
+                }
+            ]
+        },
+        {
+            "name": "下滑进入下一个直播间",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "swipe",
+                        "params": [
+                            0.9,
+                            0.7,
+                            0.9,
+                            0.3
+                        ],
+                        "identifier": "slide_in_live"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 60
+                    }
+                ]
+            }
+        }
+    ]
+}
diff --git a/examples/uitest/ios_kuaishou_follow_live_test.yaml b/examples/uitest/ios_kuaishou_follow_live_test.yaml
new file mode 100644
index 000000000..484f08152
--- /dev/null
+++ b/examples/uitest/ios_kuaishou_follow_live_test.yaml
@@ -0,0 +1,83 @@
+config:
+    name: 直播_快手_关注天窗_ios
+    variables:
+        device: ${ENV(UDID)}
+        ups: ${ENV(LIVEUPLIST)}
+    ios:
+        - udid: $device
+          port: 8100
+          mjpeg_port: 9100
+          log_on: true
+teststeps:
+    - name: 启动快手
+      ios:
+        actions:
+            - method: app_terminate
+              params: com.jiangjia.gif
+            - method: app_launch
+              params: com.jiangjia.gif
+            - method: home
+            - method: swipe_to_tap_app
+              params: 快手
+              max_retry_times: 5
+            - method: sleep
+              params: 10
+      validate:
+        - check: ui_ocr
+          assert: exists
+          expect: 精选
+          msg: 进入快手失败
+    - name: 点击首页
+      ios:
+        actions:
+            - method: tap_ocr
+              params: 首页
+              index: -1
+            - method: sleep
+              params: 10
+    - name: 点击发现页
+      ios:
+        actions:
+            - method: tap_ocr
+              params: 发现
+              index: 1
+            - method: sleep
+              params: 10
+    - name: 点击关注页
+      ios:
+        actions:
+            - method: tap_ocr
+              params: 关注
+              index: 1
+            - method: sleep
+              params: 10
+    - name: 点击直播标签,进入直播间
+      ios:
+        actions:
+            - method: swipe_to_tap_texts
+              params: ${split_by_comma($ups)}
+              identifier: click_live
+              direction:
+                - 0.6
+                - 0.2
+                - 0.2
+                - 0.2
+            - method: sleep
+              params: 60
+      validate:
+        - check: ui_ocr
+          assert: exists
+          expect: 说点什么
+          msg: 进入直播间失败
+    - name: 下滑进入下一个直播间
+      ios:
+        actions:
+            - method: swipe
+              params:
+                - 0.9
+                - 0.7
+                - 0.9
+                - 0.3
+              identifier: slide_in_live
+            - method: sleep
+              params: 60
diff --git a/examples/uitest/wda_log_data.json b/examples/uitest/wda_log_data.json
new file mode 100644
index 000000000..0237e46a1
--- /dev/null
+++ b/examples/uitest/wda_log_data.json
@@ -0,0 +1,144 @@
+{
+    "config": {
+        "name": "验证 WDA 打点数据准确性",
+        "variables": {
+            "app_name": "抖音"
+        },
+        "ios": [
+            {
+                "port": 8700,
+                "mjpeg_port": 8800,
+                "log_on": true
+            }
+        ]
+    },
+    "teststeps": [
+        {
+            "name": "启动抖音",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "home"
+                    },
+                    {
+                        "method": "app_terminate",
+                        "params": "com.ss.iphone.ugc.Aweme"
+                    },
+                    {
+                        "method": "swipe_to_tap_app",
+                        "params": "$app_name",
+                        "identifier": "启动抖音",
+                        "max_retry_times": 5
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 5
+                    }
+                ]
+            },
+            "validate": [
+                {
+                    "check": "ui_ocr",
+                    "assert": "exists",
+                    "expect": "推荐",
+                    "msg": "抖音启动失败，「推荐」不存在"
+                }
+            ]
+        },
+        {
+            "name": "处理青少年弹窗",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "我知道了",
+                        "ignore_NotFoundError": true
+                    }
+                ]
+            }
+        },
+        {
+            "name": "进入购物页",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "购物",
+                        "identifier": "点击购物"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 5
+                    }
+                ]
+            }
+        },
+        {
+            "name": "进入推荐页",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "tap_ocr",
+                        "params": "推荐",
+                        "identifier": "点击推荐"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 5
+                    }
+                ]
+            }
+        },
+        {
+            "name": "向上滑动 2 次",
+            "ios": {
+                "actions": [
+                    {
+                        "method": "swipe",
+                        "params": "up",
+                        "identifier": "第 1 次上划"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 2
+                    },
+                    {
+                        "method": "swipe",
+                        "params": "up",
+                        "identifier": "第 2 次上划"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 2
+                    },
+                    {
+                        "method": "swipe",
+                        "params": "up",
+                        "identifier": "第 3 次上划"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 2
+                    },
+                    {
+                        "method": "tap_xy",
+                        "params": [
+                            0.9,
+                            0.1
+                        ],
+                        "identifier": "点击进入搜索框"
+                    },
+                    {
+                        "method": "sleep",
+                        "params": 2
+                    },
+                    {
+                        "method": "input",
+                        "params": "httprunner",
+                        "identifier": "输入搜索关键词"
+                    }
+                ]
+            }
+        }
+    ]
+}
diff --git a/examples/uitest/wda_log_test.go b/examples/uitest/wda_log_test.go
new file mode 100644
index 000000000..54e7a0273
--- /dev/null
+++ b/examples/uitest/wda_log_test.go
@@ -0,0 +1,52 @@
+//go:build localtest
+
+package uitest
+
+import (
+	"testing"
+
+	"github.com/httprunner/httprunner/v4/hrp"
+)
+
+func TestWDALog(t *testing.T) {
+	testCase := &hrp.TestCase{
+		Config: hrp.NewConfig("验证 WDA 打点数据准确性").
+			WithVariables(map[string]interface{}{
+				"app_name": "抖音",
+			}).
+			SetIOS(hrp.WithLogOn(true), hrp.WithWDAPort(8700), hrp.WithWDAMjpegPort(8800)),
+		TestSteps: []hrp.IStep{
+			hrp.NewStep("启动抖音").
+				IOS().
+				Home().
+				AppTerminate("com.ss.iphone.ugc.Aweme"). // 关闭已运行的抖音
+				SwipeToTapApp("$app_name", hrp.WithMaxRetryTimes(5), hrp.WithIdentifier("启动抖音")).Sleep(5).
+				Validate().
+				AssertOCRExists("推荐", "抖音启动失败，「推荐」不存在"),
+			hrp.NewStep("处理青少年弹窗").
+				IOS().
+				TapByOCR("我知道了", hrp.WithIgnoreNotFoundError(true)),
+			hrp.NewStep("进入购物页").
+				IOS().TapByOCR("购物", hrp.WithIdentifier("点击购物")).Sleep(5),
+			hrp.NewStep("进入推荐页").
+				IOS().TapByOCR("推荐", hrp.WithIdentifier("点击推荐")).Sleep(5),
+			hrp.NewStep("向上滑动 2 次").
+				IOS().
+				SwipeUp(hrp.WithIdentifier("第 1 次上划")).Sleep(2).
+				SwipeUp(hrp.WithIdentifier("第 2 次上划")).Sleep(2).
+				SwipeUp(hrp.WithIdentifier("第 3 次上划")).Sleep(2).
+				TapXY(0.9, 0.1, hrp.WithIdentifier("点击进入搜索框")).Sleep(2).
+				Input("httprunner", hrp.WithIdentifier("输入搜索关键词")),
+		},
+	}
+
+	if err := testCase.Dump2JSON("wda_log_data.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/hrp/cmd/ios/xctest.go b/hrp/cmd/ios/xctest.go
new file mode 100644
index 000000000..e2b8d3433
--- /dev/null
+++ b/hrp/cmd/ios/xctest.go
@@ -0,0 +1,56 @@
+package ios
+
+import (
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+	"github.com/spf13/cobra"
+)
+
+var xctestCmd = &cobra.Command{
+	Use:   "xctest",
+	Short: "run xctest",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		if bundleID == "" {
+			return fmt.Errorf("bundleID is required")
+		}
+		device, err := getDevice(udid)
+		if err != nil {
+			return err
+		}
+
+		log.Info().Str("bundleID", bundleID).Msg("run xctest")
+		out, cancel, err := device.XCTest(bundleID)
+		if err != nil {
+			return errors.Wrap(err, "run xctest failed")
+		}
+
+		done := make(chan os.Signal, 1)
+		signal.Notify(done, syscall.SIGTERM, syscall.SIGINT)
+
+		// print xctest running logs
+		go func() {
+			for s := range out {
+				fmt.Print(s)
+			}
+			done <- os.Interrupt
+		}()
+
+		<-done
+		cancel()
+
+		return nil
+	},
+}
+
+var bundleID string
+
+func init() {
+	xctestCmd.Flags().StringVarP(&udid, "udid", "u", "", "filter by device's udid")
+	xctestCmd.Flags().StringVarP(&bundleID, "bundleID", "b", "", "specify ios bundleID")
+	iosRootCmd.AddCommand(xctestCmd)
+}
diff --git a/hrp/cmd/pytest.go b/hrp/cmd/pytest.go
index 2e1933ad7..55538e787 100644
--- a/hrp/cmd/pytest.go
+++ b/hrp/cmd/pytest.go
@@ -6,7 +6,7 @@ import (
 	"github.com/rs/zerolog/log"
 	"github.com/spf13/cobra"
 
-	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
 	"github.com/httprunner/httprunner/v4/hrp/internal/pytest"
 	"github.com/httprunner/httprunner/v4/hrp/internal/version"
 )
@@ -21,9 +21,9 @@ var pytestCmd = &cobra.Command{
 	DisableFlagParsing: true, // allow to pass any args to pytest
 	RunE: func(cmd *cobra.Command, args []string) error {
 		packages := []string{
-			fmt.Sprintf("httprunner==%s", version.VERSION),
+			fmt.Sprintf("httprunner==%s", version.HttpRunnerMinimumVersion),
 		}
-		_, err := builtin.EnsurePython3Venv(venv, packages...)
+		_, err := myexec.EnsurePython3Venv(venv, packages...)
 		if err != nil {
 			log.Error().Err(err).Msg("python3 venv is not ready")
 			return err
diff --git a/hrp/internal/code/code_test.go b/hrp/internal/code/code_test.go
new file mode 100644
index 000000000..bbf538bfe
--- /dev/null
+++ b/hrp/internal/code/code_test.go
@@ -0,0 +1,12 @@
+package code
+
+import (
+	"fmt"
+	"testing"
+)
+
+func TestGetErrorCode(t *testing.T) {
+	err := LoadYAMLError
+	code := GetErrorCode(err)
+	fmt.Println(code)
+}
diff --git a/hrp/internal/pytest/main.go b/hrp/internal/pytest/main.go
index dde5f0ae6..c9bec74b5 100644
--- a/hrp/internal/pytest/main.go
+++ b/hrp/internal/pytest/main.go
@@ -1,7 +1,7 @@
 package pytest
 
 import (
-	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
 	"github.com/httprunner/httprunner/v4/hrp/internal/sdk"
 )
 
@@ -12,5 +12,5 @@ func RunPytest(args []string) error {
 	})
 
 	args = append([]string{"run"}, args...)
-	return builtin.ExecPython3Command("httprunner", args...)
+	return myexec.ExecPython3Command("httprunner", args...)
 }
diff --git a/hrp/parser_test.go b/hrp/parser_test.go
index 5f27010b0..79756c43e 100644
--- a/hrp/parser_test.go
+++ b/hrp/parser_test.go
@@ -478,14 +478,14 @@ func TestCallBuiltinFunction(t *testing.T) {
 	parser := newParser()
 
 	// call function without arguments
-	_, err := parser.CallFunc("get_timestamp")
+	_, err := parser.callFunc("get_timestamp")
 	if !assert.NoError(t, err) {
 		t.Fatal()
 	}
 
 	// call function with one argument
 	timeStart := time.Now()
-	_, err = parser.CallFunc("sleep", 1)
+	_, err = parser.callFunc("sleep", 1)
 	if !assert.NoError(t, err) {
 		t.Fatal()
 	}
@@ -494,7 +494,7 @@ func TestCallBuiltinFunction(t *testing.T) {
 	}
 
 	// call function with one argument
-	result, err := parser.CallFunc("gen_random_string", 10)
+	result, err := parser.callFunc("gen_random_string", 10)
 	if !assert.NoError(t, err) {
 		t.Fatal()
 	}
@@ -503,7 +503,7 @@ func TestCallBuiltinFunction(t *testing.T) {
 	}
 
 	// call function with two argument
-	result, err = parser.CallFunc("max", float64(10), 9.99)
+	result, err = parser.callFunc("max", float64(10), 9.99)
 	if !assert.NoError(t, err) {
 		t.Fatal()
 	}
diff --git a/hrp/internal/boomer/boomer_test.go b/hrp/pkg/boomer/boomer_test.go
similarity index 100%
rename from hrp/internal/boomer/boomer_test.go
rename to hrp/pkg/boomer/boomer_test.go
diff --git a/hrp/internal/boomer/client_grpc_test.go b/hrp/pkg/boomer/client_grpc_test.go
similarity index 100%
rename from hrp/internal/boomer/client_grpc_test.go
rename to hrp/pkg/boomer/client_grpc_test.go
diff --git a/hrp/internal/boomer/message_test.go b/hrp/pkg/boomer/message_test.go
similarity index 100%
rename from hrp/internal/boomer/message_test.go
rename to hrp/pkg/boomer/message_test.go
diff --git a/hrp/internal/boomer/output_test.go b/hrp/pkg/boomer/output_test.go
similarity index 100%
rename from hrp/internal/boomer/output_test.go
rename to hrp/pkg/boomer/output_test.go
diff --git a/hrp/internal/boomer/ratelimiter_test.go b/hrp/pkg/boomer/ratelimiter_test.go
similarity index 100%
rename from hrp/internal/boomer/ratelimiter_test.go
rename to hrp/pkg/boomer/ratelimiter_test.go
diff --git a/hrp/internal/boomer/runner_test.go b/hrp/pkg/boomer/runner_test.go
similarity index 99%
rename from hrp/internal/boomer/runner_test.go
rename to hrp/pkg/boomer/runner_test.go
index 62d772d50..76b44cfa3 100644
--- a/hrp/internal/boomer/runner_test.go
+++ b/hrp/pkg/boomer/runner_test.go
@@ -6,9 +6,10 @@ import (
 	"testing"
 	"time"
 
-	"github.com/httprunner/httprunner/v4/hrp/internal/boomer/grpc/messager"
-	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
 	"github.com/stretchr/testify/assert"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/pkg/boomer/grpc/messager"
 )
 
 type HitOutput struct {
diff --git a/hrp/internal/boomer/server_grpc_test.go b/hrp/pkg/boomer/server_grpc_test.go
similarity index 100%
rename from hrp/internal/boomer/server_grpc_test.go
rename to hrp/pkg/boomer/server_grpc_test.go
diff --git a/hrp/internal/boomer/stats_test.go b/hrp/pkg/boomer/stats_test.go
similarity index 99%
rename from hrp/internal/boomer/stats_test.go
rename to hrp/pkg/boomer/stats_test.go
index 1d4806a26..4a8491cac 100644
--- a/hrp/internal/boomer/stats_test.go
+++ b/hrp/pkg/boomer/stats_test.go
@@ -110,7 +110,6 @@ func TestLogError(t *testing.T) {
 	if err400.occurrences != 2 {
 		t.Error("Error occurrences is wrong, expected: 2, got:", err400.occurrences)
 	}
-
 }
 
 func BenchmarkLogError(b *testing.B) {
diff --git a/hrp/internal/boomer/utils_test.go b/hrp/pkg/boomer/utils_test.go
similarity index 99%
rename from hrp/internal/boomer/utils_test.go
rename to hrp/pkg/boomer/utils_test.go
index c56d14575..a8448a975 100644
--- a/hrp/internal/boomer/utils_test.go
+++ b/hrp/pkg/boomer/utils_test.go
@@ -32,7 +32,6 @@ func TestRound(t *testing.T) {
 	if roundOne != roundTwo {
 		t.Error("round(58360) should be equal to round(58460)")
 	}
-
 }
 
 func TestGenMD5(t *testing.T) {
diff --git a/hrp/internal/convert/converter_test.go b/hrp/pkg/convert/converter_test.go
similarity index 100%
rename from hrp/internal/convert/converter_test.go
rename to hrp/pkg/convert/converter_test.go
diff --git a/hrp/internal/convert/from_curl_test.go b/hrp/pkg/convert/from_curl_test.go
similarity index 100%
rename from hrp/internal/convert/from_curl_test.go
rename to hrp/pkg/convert/from_curl_test.go
diff --git a/hrp/internal/convert/from_gotest.go b/hrp/pkg/convert/from_gotest.go
similarity index 90%
rename from hrp/internal/convert/from_gotest.go
rename to hrp/pkg/convert/from_gotest.go
index 04897c763..eecde5a5d 100644
--- a/hrp/internal/convert/from_gotest.go
+++ b/hrp/pkg/convert/from_gotest.go
@@ -7,7 +7,7 @@ import (
 	"github.com/rs/zerolog/log"
 
 	"github.com/httprunner/httprunner/v4/hrp"
-	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
+	"github.com/httprunner/httprunner/v4/hrp/internal/myexec"
 )
 
 func convert2GoTestScripts(paths ...string) error {
@@ -48,7 +48,7 @@ func convert2GoTestScripts(paths ...string) error {
 	}
 
 	// format pytest scripts with black
-	return builtin.ExecPython3Command("black", pytestPaths...)
+	return myexec.ExecPython3Command("black", pytestPaths...)
 }
 
 //go:embed testcase.tmpl
diff --git a/hrp/internal/convert/from_har_test.go b/hrp/pkg/convert/from_har_test.go
similarity index 100%
rename from hrp/internal/convert/from_har_test.go
rename to hrp/pkg/convert/from_har_test.go
diff --git a/hrp/internal/convert/from_postman_test.go b/hrp/pkg/convert/from_postman_test.go
similarity index 100%
rename from hrp/internal/convert/from_postman_test.go
rename to hrp/pkg/convert/from_postman_test.go
diff --git a/hrp/internal/convert/from_pytest.go b/hrp/pkg/convert/from_pytest.go
similarity index 100%
rename from hrp/internal/convert/from_pytest.go
rename to hrp/pkg/convert/from_pytest.go
diff --git a/hrp/internal/convert/testcase.tmpl b/hrp/pkg/convert/testcase.tmpl
similarity index 100%
rename from hrp/internal/convert/testcase.tmpl
rename to hrp/pkg/convert/testcase.tmpl
diff --git a/hrp/pkg/httpstat/demo/main_test.go b/hrp/pkg/httpstat/demo/main_test.go
new file mode 100644
index 000000000..e14bfed74
--- /dev/null
+++ b/hrp/pkg/httpstat/demo/main_test.go
@@ -0,0 +1,38 @@
+package demo
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/httprunner/httprunner/v4/hrp/pkg/httpstat"
+)
+
+func TestMain(t *testing.T) {
+	var httpStat httpstat.Stat
+
+	req, _ := http.NewRequest("GET", "https://httprunner.com", nil)
+	ctx := httpstat.WithHTTPStat(req, &httpStat)
+
+	client := &http.Client{
+		Timeout: time.Second * 10,
+	}
+
+	req = req.WithContext(ctx)
+	resp, err := client.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if resp != nil {
+		defer resp.Body.Close()
+	}
+
+	// get stat
+	httpStat.Finish()
+	result := httpStat.Durations()
+	fmt.Println(result)
+
+	// print stat
+	httpStat.Print()
+}
diff --git a/hrp/pkg/uixt/android_device_test.go b/hrp/pkg/uixt/android_device_test.go
new file mode 100644
index 000000000..2167d2f58
--- /dev/null
+++ b/hrp/pkg/uixt/android_device_test.go
@@ -0,0 +1,18 @@
+package uixt
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/httprunner/httprunner/v4/hrp/internal/json"
+)
+
+func TestConvertPoints(t *testing.T) {
+	data := "10-09 20:16:48.216 I/iesqaMonitor(17845): {\"duration\":0,\"end\":1665317808206,\"ext\":\"输入\",\"from\":{\"x\":0.0,\"y\":0.0},\"operation\":\"Gtf-SendKeys\",\"run_time\":627,\"start\":1665317807579,\"start_first\":0,\"start_last\":0,\"to\":{\"x\":0.0,\"y\":0.0}}\n10-09 20:18:22.899 I/iesqaMonitor(17845): {\"duration\":0,\"end\":1665317902898,\"ext\":\"进入直播间\",\"from\":{\"x\":717.0,\"y\":2117.5},\"operation\":\"Gtf-Tap\",\"run_time\":121,\"start\":1665317902777,\"start_first\":0,\"start_last\":0,\"to\":{\"x\":717.0,\"y\":2117.5}}\n10-09 20:18:32.063 I/iesqaMonitor(17845): {\"duration\":0,\"end\":1665317912062,\"ext\":\"第一次上划\",\"from\":{\"x\":1437.0,\"y\":2409.9},\"operation\":\"Gtf-Swipe\",\"run_time\":32,\"start\":1665317912030,\"start_first\":0,\"start_last\":0,\"to\":{\"x\":1437.0,\"y\":2409.9}}"
+	eps := ConvertPoints(data)
+	if len(eps) != 3 {
+		t.Fatal()
+	}
+	jsons, _ := json.Marshal(eps)
+	println(fmt.Sprintf("%v", string(jsons)))
+}
diff --git a/hrp/pkg/uixt/android_test.go b/hrp/pkg/uixt/android_test.go
new file mode 100644
index 000000000..eecf7b914
--- /dev/null
+++ b/hrp/pkg/uixt/android_test.go
@@ -0,0 +1,1307 @@
+//go:build localtest
+
+package uixt
+
+import (
+	"io/ioutil"
+	"testing"
+	"time"
+)
+
+var uiaServerURL = "http://localhost:6790/wd/hub"
+
+func TestDriver_NewSession(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	firstMatchEntry := make(map[string]interface{})
+	firstMatchEntry["package"] = "com.android.settings"
+	firstMatchEntry["activity"] = "com.android.settings/.Settings"
+	caps := Capabilities{
+		"firstMatch":  []interface{}{firstMatchEntry},
+		"alwaysMatch": struct{}{},
+	}
+	session, err := driver.NewSession(caps)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(session.SessionId) == 0 {
+		t.Fatal("should not be empty")
+	}
+}
+
+func TestNewDriver(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(driver.sessionId)
+}
+
+func TestDriver_Quit(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if err = driver.Close(); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestDriver_Status(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	_, err = driver.Status()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestDriver_SessionIDs(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	sessions, err := driver.SessionIDs()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(sessions) == 0 {
+		t.Fatal("should have at least one")
+	}
+	t.Log(len(sessions), sessions)
+}
+
+func TestDriver_SessionDetails(t *testing.T) {
+	// firstMatchEntry := make(map[string]interface{})
+	// firstMatchEntry["package"] = "com.android.settings"
+	// firstMatchEntry["activity"] = "com.android.settings/.Settings"
+	// caps = Capabilities{
+	// 	"firstMatch":  []interface{}{firstMatchEntry},
+	// 	"alwaysMatch": struct{}{},
+	// }
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	scrollData, err := driver.SessionDetails()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(scrollData)
+}
+
+func TestDriver_Screenshot(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	screenshot, err := driver.Screenshot()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(ioutil.WriteFile("/Users/hero/Desktop/s1.png", screenshot.Bytes(), 0o600))
+}
+
+func TestDriver_Orientation(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	orientation, err := driver.Orientation()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(orientation)
+}
+
+func TestDriver_Rotation(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rotation, err := driver.Rotation()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Logf("x = %d\ty = %d\tz = %d", rotation.X, rotation.Y, rotation.Z)
+}
+
+func TestDriver_DeviceSize(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	deviceSize, err := driver.WindowSize()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Logf("width = %d\theight = %d", deviceSize.Width, deviceSize.Height)
+}
+
+func TestDriver_Source(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	source, err := driver.Source()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(source)
+}
+
+func TestDriver_BatteryInfo(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	batteryInfo, err := driver.BatteryInfo()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(batteryInfo)
+}
+
+func TestDriver_GetAppiumSettings(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	appiumSettings, err := driver.GetAppiumSettings()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	for k := range appiumSettings {
+		t.Logf("key: %s\tvalue: %v", k, appiumSettings[k])
+	}
+	// t.Log(appiumSettings)
+}
+
+func TestDriver_DeviceScaleRatio(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	scaleRatio, err := driver.Scale()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(scaleRatio)
+}
+
+func TestDriver_DeviceInfo(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	devInfo, err := driver.DeviceInfo()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Logf("api version: %s", devInfo.APIVersion)
+	t.Logf("platform version: %s", devInfo.PlatformVersion)
+	t.Logf("bluetooth state: %s", devInfo.Bluetooth.State)
+}
+
+func TestDriver_AlertText(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	alertText, err := driver.AlertText()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(alertText)
+}
+
+func TestDriver_Tap(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = driver.Tap(150, 340)
+	if err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(time.Second)
+
+	err = driver.TapFloat(60.5, 125.5)
+	if err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(time.Second)
+}
+
+func TestDriver_Swipe(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = driver.Swipe(400, 1000, 400, 500)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = driver.SwipeFloat(400, 555.5, 400, 1255.5)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestDriver_Drag(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = driver.Drag(400, 260, 400, 500)
+	if err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(time.Millisecond * 200)
+
+	err = driver.DragFloat(400, 501.5, 400, 261.5)
+	if err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(time.Millisecond * 200)
+}
+
+func TestDriver_SendKeys(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = driver.SendKeys("abc")
+	if err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(time.Second * 2)
+
+	err = driver.SendKeys("def")
+	if err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(time.Second * 2)
+
+	err = driver.SendKeys("\\n")
+	// err = driver.SendKeys(`\n`, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+//func TestDriver_PressBack(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = driver.PressBack()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+
+//func TestDriver_PressKeyCode(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = driver.PressKeyCodeAsync(KCx)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	err = driver.PressKeyCodeAsync(KCx, KMCapLocked)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	// err = driver.PressKeyCodeAsync(KCExplorer)
+//	// if err != nil {
+//	// 	t.Fatal(err)
+//	// }
+//
+//	err = driver.PressKeyCode(KCExplorer, KMEmpty)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+
+//func TestDriver_LongPressKeyCode(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = driver.LongPressKeyCode(KCAt, KMEmpty)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+//
+//func TestDriver_TouchDown(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	doTouchUp := func() {
+//		err = driver.TouchUp(400, 260)
+//		if err != nil {
+//			t.Fatal(err)
+//		}
+//	}
+//
+//	err = driver.TouchDown(400, 260)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	// _ = driver.TapPoint(Point{400, 500})
+//	doTouchUp()
+//
+//	err = driver.TouchDownPoint(Point{400, 260})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	doTouchUp()
+//}
+//
+//func TestDriver_TouchUp(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = driver.TouchDown(400, 260)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	// err = driver.TouchUp(400, 260)
+//	err = driver.TouchUpPoint(Point{400, 260})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+//
+//func TestDriver_TouchMove(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	doTouchDown := func(x, y int) {
+//		err = driver.TouchDown(x, y)
+//		if err != nil {
+//			t.Fatal(err)
+//		}
+//	}
+//
+//	doTouchUp := func(x, y int) {
+//		err = driver.TouchUp(x, y)
+//		if err != nil {
+//			t.Fatal(err)
+//		}
+//	}
+//
+//	doTouchDown(400, 260)
+//
+//	err = driver.TouchMove(400, 500)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	doTouchUp(400, 500)
+//
+//	doTouchDown(400, 500)
+//
+//	err = driver.TouchMove(400, 260)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	doTouchUp(400, 260)
+//}
+//
+//func TestDriver_OpenNotification(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = driver.OpenNotification()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+//
+//func TestDriver_Flick(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = driver.Flick(50, -100)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+//
+//func TestDriver_ScrollTo(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = driver.ScrollTo(BySelector{ClassName: "android.widget.SeekBar"})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+
+//func TestDriver_MultiPointerGesture(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	gesture1 := NewTouchAction().Add(150, 340, 0.35).AddFloat(50, 300)
+//	gesture2 := NewTouchAction().Add(200, 340).AddFloat(300, 300)
+//	gesture3 := NewTouchAction().Add(300, 500).AddFloat(350, 500).AddPoint(Point{300, 550}).AddPointF(PointF{350, 550})
+//	_ = gesture3
+//
+//	// err = driver.MultiPointerGesture(gesture1, gesture2)
+//	err = driver.MultiPointerGesture(gesture1, gesture2, gesture3)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+//
+//func TestDriver_PerformW3CActions(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	// actionKey := NewW3CAction(ATKey, NewW3CGestures().KeyDown("g").KeyUp("g").Pause().KeyDown("o").KeyUp("o"))
+//	// actionKey := NewW3CAction(ATKey, NewW3CGestures().SendKeys("golang"))
+//	// err = driver.PerformW3CActions(actionKey)
+//	// if err != nil {
+//	// 	t.Fatal(err)
+//	// }
+//
+//	// var queryField map[string]string
+//	// queryField = make(map[string]string)
+//	// {
+//	// 	queryField = map[string]string{
+//	// 		"a": "",
+//	// 	}
+//	// }
+//
+//	elem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/search"})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	// actionPointer := NewW3CAction(ATPointer, NewW3CGestures().PointerMove(0, 0, elem.id).PointerDown().Pause(3).PointerUp())
+//	// actionPointer := NewW3CAction(ATPointer,
+//	// 	NewW3CGestures().PointerMove(400, 500, "viewport").PointerDown().Pause(2).
+//	// 		PointerMove(0, 0, elem.id).Pause(2).
+//	// 		PointerMove(20, 0, "pointer").Pause(2).
+//	// 		PointerUp(),
+//	// )
+//	actionPointer := NewW3CAction(ATPointer,
+//		NewW3CGestures().PointerMoveTo(400, 500).PointerDown().
+//			PointerMouseOver(0, 0, elem).
+//			PointerMoveRelative(20, 0).PointerUp())
+//	err = driver.PerformW3CActions(actionPointer)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+//
+//func TestDriver_GetClipboard(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	text, err := driver.GetClipboard()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	t.Log(text)
+//}
+//
+//func TestDriver_SetClipboard(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	content := "test123"
+//	err = driver.SetClipboard(ClipDataTypePlaintext, content)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	text, err := driver.GetClipboard()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	if text != content {
+//		t.Fatal("should be the same")
+//	}
+//}
+
+func TestDriver_AlertAccept(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = driver.AlertAccept()
+	// err = driver.AlertAccept("是")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestDriver_AlertDismiss(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// err = driver.AlertDismiss()
+	err = driver.AlertDismiss("否")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+//func TestDriver_SetAppiumSettings(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	appiumSettings, err := driver.GetAppiumSettings()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	sdopd := appiumSettings["shutdownOnPowerDisconnect"]
+//	t.Log("shutdownOnPowerDisconnect:", sdopd)
+//
+//	err = driver.SetAppiumSettings(map[string]interface{}{"shutdownOnPowerDisconnect": !sdopd.(bool)})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	appiumSettings, err = driver.GetAppiumSettings()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	if appiumSettings["shutdownOnPowerDisconnect"] == sdopd.(bool) {
+//		t.Fatal("should not be equal")
+//	}
+//	t.Log("shutdownOnPowerDisconnect:", appiumSettings["shutdownOnPowerDisconnect"])
+//}
+
+func TestDriver_SetOrientation(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = driver.SetOrientation(OrientationLandscapeLeft)
+	// err = driver.SetOrientation(OrientationPortrait)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestDriver_SetRotation(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// err = driver.SetRotation(Rotation{Z: 0})
+	err = driver.SetRotation(Rotation{Z: 270})
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+//func TestDriver_NetworkConnection(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = driver.NetworkConnection(NetworkTypeWifi)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+
+func TestDriver_FindElement(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "android:id/content"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	e := ElementAttribute{}.WithLabel("class")
+	t.Log(elem.GetAttribute(e))
+}
+
+func TestDriver_FindElements(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// elements, err := driver.FindElements(BySelector{ResourceIdID: "com.android.settings:id/title"})
+	elements, err := driver.FindElements(BySelector{UiAutomator: "new UiSelector().textStartsWith(\"应\");"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(len(elements))
+}
+
+func TestDriver_WaitWithTimeoutAndInterval(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	element, err := driver.FindElement(BySelector{UiAutomator: "new UiSelector().className(\"android.view.ViewGroup\");"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := element.FindElement(BySelector{UiAutomator: "new UiSelector().className(\"android.widget.LinearLayout\").index(6);"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rect, err := elem.Rect()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	x := rect.X + int(float64(rect.Width)*2)
+	y := rect.Y + rect.Height/2
+	err = driver.Tap(x, y)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	by := BySelector{UiAutomator: "new UiSelector().text(\"科技\");"}
+	exists := func(d WebDriver) (bool, error) {
+		element, err = d.FindElement(by)
+		if err == nil {
+			return true, nil
+		}
+		return false, nil
+	}
+
+	err = driver.WaitWithTimeoutAndInterval(exists, 1, 1)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// element, err = driver.FindElement(by)
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+
+	err = element.Click()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+//func TestDriver_ActiveElement(t *testing.T) {
+//	device, _ := NewAndroidDevice()
+//	driver, err := device.NewUSBDriver(nil)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	defer func() {
+//		_ = driver.Dispose()
+//	}()
+//
+//	element, err := driver.ActiveElement()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	if err = element.SendKeys("test"); err != nil {
+//		t.Fatal(err)
+//	}
+//}
+
+func TestUiSelectorHelper_NewUiSelectorHelper(t *testing.T) {
+	uiSelector := NewUiSelectorHelper().Text("a").String()
+	if uiSelector != `new UiSelector().text("a");` {
+		t.Fatal("[ERROR]", uiSelector)
+	}
+
+	uiSelector = NewUiSelectorHelper().Text("a").TextStartsWith("b").String()
+	if uiSelector != `new UiSelector().text("a").textStartsWith("b");` {
+		t.Fatal("[ERROR]", uiSelector)
+	}
+
+	uiSelector = NewUiSelectorHelper().ClassName("android.widget.LinearLayout").Index(6).String()
+	if uiSelector != `new UiSelector().className("android.widget.LinearLayout").index(6);` {
+		t.Fatal("[ERROR]", uiSelector)
+	}
+
+	uiSelector = NewUiSelectorHelper().Focused(false).Instance(6).String()
+	if uiSelector != `new UiSelector().focused(false).instance(6);` {
+		t.Fatal("[ERROR]", uiSelector)
+	}
+
+	uiSelector = NewUiSelectorHelper().ChildSelector(NewUiSelectorHelper().Enabled(true)).String()
+	if uiSelector != `new UiSelector().childSelector(new UiSelector().enabled(true));` {
+		t.Fatal("[ERROR]", uiSelector)
+	}
+}
+
+func Test_getFreePort(t *testing.T) {
+	freePort, err := getFreePort()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(freePort)
+}
+
+func TestDeviceList(t *testing.T) {
+	devices, err := DeviceList()
+	if err != nil {
+		t.Fatal(err)
+	}
+	for i := range devices {
+		t.Log(devices[i].Serial())
+	}
+}
+
+//func TestAndroidNewUSBDriver(t *testing.T) {
+//	device, _ := NewAndroidDevice()
+//	driver, err := device.NewUSBDriver(nil)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	defer driver.Dispose()
+//
+//	ready, err := driver.Status()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	if !ready {
+//		t.Fatal("should be 'true'")
+//	}
+//}
+
+//func TestDriver_ActiveAppPackageName(t *testing.T) {
+//	device, _ := NewAndroidDevice()
+//	driver, err := device.NewUSBDriver(nil)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	defer driver.Dispose()
+//
+//	appPackageName, err := driver.ActiveAppPackageName()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	t.Log(appPackageName)
+//}
+
+func TestDriver_AppLaunch(t *testing.T) {
+	device, _ := NewAndroidDevice()
+	driver, err := device.NewUSBDriver(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// err = driver.AppLaunch("tv.danmaku.bili", BySelector{ResourceIdID: "tv.danmaku.bili:id/action_bar_root"})
+	err = driver.AppLaunch("com.android.settings", AppLaunchOption{}.WithAndroidBySelector(AndroidBySelector{ResourceIdID: "android:id/list"}))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// screenshot, err := driver.Screenshot()
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+	// t.Log(ioutil.WriteFile("/Users/hero/Desktop/s1.png", screenshot.Bytes(), 0600))
+}
+
+func TestDriver_AppTerminate(t *testing.T) {
+	device, _ := NewAndroidDevice()
+	driver, err := device.NewUSBDriver(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer driver.Dispose()
+
+	_, err = driver.AppTerminate("tv.danmaku.bili")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+//func TestNewWiFiDriver(t *testing.T) {
+//	device, _ := NewAndroidDevice(WithAdbIP("192.168.1.28"))
+//	driver, err := device.NewHTTPDriver(nil)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	// SetDebug(false, true)
+//	_, err = driver.ActiveAppActivity()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+
+//func TestDriver_AppInstall(t *testing.T) {
+//	device, _ := NewAndroidDevice()
+//	driver, err := device.NewUSBDriver(nil)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	defer driver.Dispose()
+//
+//	err = driver.AppInstall("/Users/hero/Desktop/xuexi_android_10002068.apk")
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+
+//func TestDriver_AppUninstall(t *testing.T) {
+//	device, _ := NewAndroidDevice()
+//	driver, err := device.NewUSBDriver(nil)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//	defer driver.Dispose()
+//
+//	err = driver.AppUninstall("cn.xuexi.android")
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+
+func TestBySelector_getMethodAndSelector(t *testing.T) {
+	testVal := "test id"
+	bySelector := BySelector{ResourceIdID: testVal}
+	method, selector := bySelector.getMethodAndSelector()
+	if method != "id" || selector != testVal {
+		t.Fatal(method, "=", selector)
+	}
+
+	bySelector = BySelector{ContentDescription: testVal}
+	method, selector = bySelector.getMethodAndSelector()
+	if method != "accessibility id" || selector != testVal {
+		t.Fatal(method, "=", selector)
+	}
+}
+
+func TestElement_Text(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/category_title"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	text, err := elem.Text()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(text)
+}
+
+func TestElement_GetAttribute(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/category_title"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	e := ElementAttribute{}.WithName("class")
+	attribute, err := elem.GetAttribute(e)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(attribute)
+}
+
+//func TestElement_ContentDescription(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	elem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/search"})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	name, err := elem.ContentDescription()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	t.Log(name)
+//}
+
+func TestElement_Size(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/search"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	size, err := elem.Size()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(size)
+}
+
+func TestElement_Rect(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/category_title"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rect, err := elem.Rect()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(rect)
+}
+
+func TestElement_Screenshot(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/category_title"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	screenshot, err := elem.Screenshot()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(ioutil.WriteFile("/Users/hero/Desktop/e1.png", screenshot.Bytes(), 0o600))
+}
+
+func TestElement_Location(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/category_title"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	location, err := elem.Location()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(location)
+}
+
+func TestElement_Click(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/title"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = elem.Click()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestElement_Clear(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "android:id/search_src_text"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = elem.Clear()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestElement_SendKeys(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "android:id/search_src_text"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// return
+
+	// err = elem.SendKeys("abc")
+	err = elem.SendKeys("456")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestElement_FindElements(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	parentElem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/main_content"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elements, err := parentElem.FindElements(BySelector{ResourceIdID: "com.android.settings:id/category"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(len(elements))
+}
+
+func TestElement_FindElement(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	parentElem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/main_content"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := parentElem.FindElement(BySelector{ResourceIdID: "com.android.settings:id/category_title"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(elem.Text())
+}
+
+func TestElement_Swipe(t *testing.T) {
+	driver, err := NewUIADriver(nil, uiaServerURL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	elem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/category_title"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rect, err := elem.Rect()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(rect)
+
+	var startX, startY, endX, endY int
+	startX = rect.X + rect.Width/20
+	startY = rect.Y + rect.Height/2
+	endX = startX
+	endY = startY - startY/2
+	err = elem.Swipe(startX, startY, endX, endY)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+//func TestElement_Drag(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	elements, err := driver.FindElements(BySelector{ClassName: "android.widget.TextView"})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	for i, elem := range elements {
+//		text, _ := elem.Text()
+//		t.Log(i, text)
+//	}
+//
+//	rect, err := elements[0].Rect()
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	// err = elements[0].Drag(300, 450, 256)
+//	err = elements[0].Drag(300, 450, 256)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = elements[0].DragTo(elements[1], 256)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	endPoint := PointF{X: float64(rect.X + rect.Width/3*2), Y: float64(rect.Y + rect.Height/2)}
+//	err = elements[0].DragPointF(endPoint, 256)
+//	if err != nil {
+//		t.Fatal()
+//	}
+//}
+
+//func TestElement_Flick(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	elem, err := driver.FindElement(BySelector{UiAutomator: "new UiSelector().text(\"提示音和通知\");"})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = elem.Flick(36, 20, 100)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+
+//func TestElement_ScrollTo(t *testing.T) {
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	// how to make it work?
+//	// parentElem, err := driver.FindElement(BySelector{ClassName: "android.widget.ScrollView"})
+//	// parentElem, err := driver.FindElement(BySelector{ResourceIdID: "com.cyanogenmod.filemanager:id/navigation_view_layout"})
+//	parentElem, err := driver.FindElement(BySelector{ResourceIdID: "com.android.settings:id/dashboard"})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = parentElem.ScrollTo(BySelector{ContentDescription: "电池"})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
+
+//func TestElement_ScrollToElement(t *testing.T) {
+//	// android.widget.HorizontalScrollView
+//	driver, err := NewUIADriver(nil, uiaServerURL)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	// how to make it work?
+//	parentElem, err := driver.FindElement(BySelector{UiAutomator: "new UiSelector().resourceId(\"com.android.settings:id/dashboard\");"})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	element, err := driver.FindElement(BySelector{UiAutomator: "new UiSelector().text(\"电池\");"})
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//
+//	err = parentElem.ScrollToElement(element)
+//	if err != nil {
+//		t.Fatal(err)
+//	}
+//}
diff --git a/hrp/pkg/uixt/demo/main_test.go b/hrp/pkg/uixt/demo/main_test.go
new file mode 100644
index 000000000..1ff036cef
--- /dev/null
+++ b/hrp/pkg/uixt/demo/main_test.go
@@ -0,0 +1,46 @@
+//go:build localtest
+
+package demo
+
+import (
+	"testing"
+	"time"
+
+	"github.com/httprunner/httprunner/v4/hrp/pkg/uixt"
+)
+
+func TestIOSDemo(t *testing.T) {
+	device, err := uixt.NewIOSDevice(
+		uixt.WithWDAPort(8700), uixt.WithWDAMjpegPort(8800),
+		uixt.WithResetHomeOnStartup(false), // not reset home on startup
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	capabilities := uixt.NewCapabilities()
+	capabilities.WithDefaultAlertAction(uixt.AlertActionAccept) // or uixt.AlertActionDismiss
+	driverExt, err := device.NewDriver(capabilities)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// release session
+	defer func() {
+		driverExt.Driver.DeleteSession()
+	}()
+
+	// 持续监测手机屏幕，直到出现青少年模式弹窗后，点击「我知道了」
+	for {
+		points, err := driverExt.GetTextXYs([]string{"青少年模式", "我知道了"})
+		if err != nil {
+			time.Sleep(1 * time.Second)
+			continue
+		}
+
+		err = driverExt.TapAbsXY(points[1].X, points[1].Y)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+}
diff --git a/hrp/pkg/uixt/drag_test.go b/hrp/pkg/uixt/drag_test.go
new file mode 100644
index 000000000..59a8fbfb3
--- /dev/null
+++ b/hrp/pkg/uixt/drag_test.go
@@ -0,0 +1,20 @@
+//go:build localtest
+
+package uixt
+
+import (
+	"testing"
+)
+
+func TestDriverExt_Drag(t *testing.T) {
+	driverExt, err := iosDevice.NewDriver(nil)
+	checkErr(t, err)
+
+	pathSearch := "/Users/hero/Documents/temp/2020-05/opencv/IMG_map.png"
+
+	// err = driverExt.Drag(pathSearch, 300, 500, 2)
+	// checkErr(t, err)
+
+	err = driverExt.DragOffset(pathSearch, 300, 500, 2.1, 0.5, 2)
+	checkErr(t, err)
+}
diff --git a/hrp/pkg/uixt/gesture_test.go b/hrp/pkg/uixt/gesture_test.go
new file mode 100644
index 000000000..c3b8dcadc
--- /dev/null
+++ b/hrp/pkg/uixt/gesture_test.go
@@ -0,0 +1,25 @@
+//go:build opencv
+
+package uixt
+
+import (
+	"strconv"
+	"strings"
+	"testing"
+)
+
+func TestDriverExt_GesturePassword(t *testing.T) {
+	split := strings.Split("6304258", "")
+	password := make([]int, len(split))
+	for i := range split {
+		password[i], _ = strconv.Atoi(split[i])
+	}
+
+	driverExt, err := iosDevice.NewDriver(nil)
+	checkErr(t, err)
+
+	pathSearch := "/Users/hero/Documents/temp/2020-05/opencv/IMG_5.png"
+
+	err = driverExt.GesturePassword(pathSearch, password...)
+	checkErr(t, err)
+}
diff --git a/hrp/pkg/uixt/ios_test.go b/hrp/pkg/uixt/ios_test.go
new file mode 100644
index 000000000..073773174
--- /dev/null
+++ b/hrp/pkg/uixt/ios_test.go
@@ -0,0 +1,1174 @@
+//go:build localtest
+
+package uixt
+
+import (
+	"bytes"
+	"fmt"
+	"math"
+	"testing"
+	"time"
+)
+
+var (
+	bundleId = "com.apple.Preferences"
+	driver   WebDriver
+)
+
+func setup(t *testing.T) {
+	device, err := NewIOSDevice()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	driver, err = device.NewUSBDriver(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestViaUSB(t *testing.T) {
+	setup(t)
+	t.Log(driver.Status())
+}
+
+func TestNewIOSDevice(t *testing.T) {
+	device, _ := NewIOSDevice()
+	if device != nil {
+		t.Log(device)
+	}
+
+	device, _ = NewIOSDevice(WithUDID("xxxx"))
+	if device != nil {
+		t.Log(device)
+	}
+
+	device, _ = NewIOSDevice(WithWDAPort(8700), WithWDAMjpegPort(8800))
+	if device != nil {
+		t.Log(device)
+	}
+
+	device, _ = NewIOSDevice(WithUDID("xxxx"), WithWDAPort(8700), WithWDAMjpegPort(8800))
+	if device != nil {
+		t.Log(device)
+	}
+}
+
+func TestNewWDAHTTPDriver(t *testing.T) {
+	device, _ := NewIOSDevice()
+	var err error
+	_, err = device.NewHTTPDriver(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestNewUSBDriver(t *testing.T) {
+	setup(t)
+
+	// t.Log(driver.IsWdaHealthy())
+}
+
+func Test_remoteWD_NewSession(t *testing.T) {
+	setup(t)
+
+	// sessionInfo, err := driver.NewSession(nil)
+	sessionInfo, err := driver.NewSession(
+		NewCapabilities().WithAppLaunchOption(
+			NewAppLaunchOption().WithBundleId(bundleId).WithArguments([]string{"-AppleLanguages", "(Russian)"}),
+		),
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(sessionInfo.SessionId) == 0 {
+		t.Fatal(sessionInfo)
+	}
+}
+
+func Test_remoteWD_ActiveSession(t *testing.T) {
+	setup(t)
+
+	sessionInfo, err := driver.ActiveSession()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(sessionInfo.SessionId) == 0 {
+		t.Fatal(sessionInfo)
+	}
+}
+
+func Test_remoteWD_DeleteSession(t *testing.T) {
+	setup(t)
+
+	err := driver.DeleteSession()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_HealthCheck(t *testing.T) {
+	setup(t)
+
+	err := driver.HealthCheck()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_GetAppiumSettings(t *testing.T) {
+	setup(t)
+
+	settings, err := driver.GetAppiumSettings()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(settings)
+}
+
+func Test_remoteWD_SetAppiumSettings(t *testing.T) {
+	setup(t)
+
+	const _acceptAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'允许','好','仅在使用应用期间','暂不'}`]"
+	const _dismissAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'不允许','暂不'}`]"
+
+	key := "acceptAlertButtonSelector"
+	value := _acceptAlertButtonSelector
+
+	// settings, err := driver.SetAppiumSettings(map[string]interface{}{"dismissAlertButtonSelector": "暂不"})
+	settings, err := driver.SetAppiumSettings(map[string]interface{}{key: value})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if settings[key] != value {
+		t.Fatal(settings[key])
+	}
+}
+
+func Test_remoteWD_IsWdaHealthy(t *testing.T) {
+	setup(t)
+
+	healthy, err := driver.IsHealthy()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if healthy == false {
+		t.Fatal("healthy =", healthy)
+	}
+}
+
+// func Test_remoteWD_WdaShutdown(t *testing.T) {
+// 	setup(t)
+//
+// 	if err := driver.WdaShutdown(); err != nil {
+// 		t.Fatal(err)
+// 	}
+// }
+
+func Test_remoteWD_Status(t *testing.T) {
+	setup(t)
+
+	status, err := driver.Status()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if status.Ready == false {
+		t.Fatal("deviceStatus =", status)
+	}
+}
+
+func Test_remoteWD_DeviceInfo(t *testing.T) {
+	setup(t)
+
+	info, err := driver.DeviceInfo()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(info.Model) == 0 {
+		t.Fatal(info)
+	}
+}
+
+func Test_remoteWD_BatteryInfo(t *testing.T) {
+	setup(t)
+
+	batteryInfo, err := driver.BatteryInfo()
+	if err != nil {
+		t.Fatal()
+	}
+	t.Log(batteryInfo)
+}
+
+func Test_remoteWD_WindowSize(t *testing.T) {
+	setup(t)
+
+	size, err := driver.WindowSize()
+	if err != nil {
+		t.Fatal()
+	}
+	t.Log(size)
+}
+
+func Test_remoteWD_Screen(t *testing.T) {
+	setup(t)
+
+	screen, err := driver.Screen()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(screen)
+}
+
+func Test_remoteWD_ActiveAppInfo(t *testing.T) {
+	setup(t)
+
+	appInfo, err := driver.ActiveAppInfo()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(appInfo.BundleId) == 0 {
+		t.Fatal(appInfo)
+	}
+	t.Log(appInfo)
+}
+
+func Test_remoteWD_ActiveAppsList(t *testing.T) {
+	setup(t)
+
+	appsList, err := driver.ActiveAppsList()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(appsList) == 0 {
+		t.Fatal(appsList)
+	}
+	t.Log(appsList)
+}
+
+func Test_remoteWD_AppState(t *testing.T) {
+	setup(t)
+
+	runState, err := driver.AppState(bundleId)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(runState)
+}
+
+func Test_remoteWD_IsLocked(t *testing.T) {
+	setup(t)
+
+	locked, err := driver.IsLocked()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(locked)
+}
+
+func Test_remoteWD_Unlock(t *testing.T) {
+	setup(t)
+
+	err := driver.Unlock()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_Lock(t *testing.T) {
+	setup(t)
+
+	err := driver.Lock()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_AlertText(t *testing.T) {
+	setup(t)
+
+	text, err := driver.AlertText()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = text
+	t.Log(text)
+}
+
+func Test_remoteWD_AlertButtons(t *testing.T) {
+	setup(t)
+
+	btnLabels, err := driver.AlertButtons()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(btnLabels)
+}
+
+func Test_remoteWD_AlertAccept(t *testing.T) {
+	// Test_remoteWD_AppAuthReset(t)
+	// return
+
+	setup(t)
+
+	err := driver.AlertAccept()
+	// err := driver.AlertAccept("")
+	// err := driver.AlertAccept("好")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_AlertDismiss(t *testing.T) {
+	// Test_remoteWD_AppAuthReset(t)
+	// return
+
+	setup(t)
+
+	err := driver.AlertDismiss()
+	// err := driver.AlertDismiss("")
+	// err := driver.AlertDismiss("不允许")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_AlertSendKeys(t *testing.T) {
+	setup(t)
+
+	err := driver.AlertSendKeys("todo")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_Homescreen(t *testing.T) {
+	setup(t)
+
+	err := driver.Homescreen()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_AppLaunch(t *testing.T) {
+	setup(t)
+
+	// bundleId = "com.hustlzp.xcz"
+	// bundleId = "com.github.stormbreaker.prod"
+	// bundleId = "com.360buy.jdmobile"
+	// bundleId = "com.zhihu.ios"
+	// bundleId = "com.tencent.xin"
+	// bundleId = "com.jsmcc.ZP7267A6ES"
+	err := driver.AppLaunch(bundleId)
+	// err := driver.AppLaunch(bundleId, NewAppLaunchOption().WithShouldWaitForQuiescence(true))
+	// err := driver.AppLaunch(bundleId, NewAppLaunchOption().WithArguments([]string{"-AppleLanguages", "(Russian)"}))
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_AppLaunchUnattached(t *testing.T) {
+	setup(t)
+
+	err := driver.AppLaunchUnattached(bundleId)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_AppTerminate(t *testing.T) {
+	setup(t)
+
+	_, err := driver.AppTerminate(bundleId)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_AppActivate(t *testing.T) {
+	setup(t)
+
+	err := driver.AppActivate(bundleId)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_AppDeactivate(t *testing.T) {
+	setup(t)
+
+	err := driver.AppDeactivate(2)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_AppAuthReset(t *testing.T) {
+	setup(t)
+
+	err := driver.AppAuthReset(ProtectedResourceCamera)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_Tap(t *testing.T) {
+	setup(t)
+
+	err := driver.Tap(200, 300)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_DoubleTap(t *testing.T) {
+	setup(t)
+
+	err := driver.DoubleTap(200, 300)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_TouchAndHold(t *testing.T) {
+	setup(t)
+
+	// err := driver.TouchAndHold(200, 300)
+	err := driver.TouchAndHold(200, 300, -1)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_Drag(t *testing.T) {
+	setup(t)
+
+	// err := driver.Drag(200, 300, 200, 500, WithDataPressDuration(0.5))
+	err := driver.Swipe(200, 300, 200, 500)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_ForceTouch(t *testing.T) {
+	setup(t)
+
+	err := driver.ForceTouch(256, 400, 0.8, -1)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_SetPasteboard(t *testing.T) {
+	setup(t)
+
+	// err := driver.SetPasteboard(PasteboardTypePlaintext, "gwda")
+	err := driver.SetPasteboard(PasteboardTypeUrl, "Clock-stopwatch://")
+	// userHomeDir, _ := os.UserHomeDir()
+	// bytesImg, _ := ioutil.ReadFile(userHomeDir + "/Pictures/IMG_0806.jpg")
+	// err := driver.SetPasteboard(PasteboardTypeImage, string(bytesImg))
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_GetPasteboard(t *testing.T) {
+	setup(t)
+
+	var buffer *bytes.Buffer
+	var err error
+
+	buffer, err = driver.GetPasteboard(PasteboardTypePlaintext)
+	// buffer, err = driver.GetPasteboard(PasteboardTypeUrl)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(buffer.String())
+
+	// buffer, err = driver.GetPasteboard(PasteboardTypeImage)
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+	// userHomeDir, _ := os.UserHomeDir()
+	// if err = ioutil.WriteFile(userHomeDir+"/Desktop/p1.png", buffer.Bytes(), 0600); err != nil {
+	// 	t.Error(err)
+	// }
+}
+
+func Test_remoteWD_SendKeys(t *testing.T) {
+	setup(t)
+
+	err := driver.SendKeys("App Store")
+	// err := driver.SendKeys("App Store", WithFrequency(3))
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_PressButton(t *testing.T) {
+	setup(t)
+
+	err := driver.PressButton(DeviceButtonVolumeUp)
+	if err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(time.Second * 1)
+	err = driver.PressButton(DeviceButtonVolumeDown)
+	if err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(time.Second * 1)
+	err = driver.PressButton(DeviceButtonHome)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_SiriActivate(t *testing.T) {
+	setup(t)
+
+	err := driver.SiriActivate("What's the weather like today")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_SiriOpenUrl(t *testing.T) {
+	setup(t)
+
+	err := driver.SiriOpenUrl("Prefs:root=Bluetooth")
+	// err := driver.SiriOpenUrl("Prefs:root=WIFI![]()")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_Orientation(t *testing.T) {
+	setup(t)
+
+	orientation, err := driver.Orientation()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if orientation == "" {
+		t.Fatal(orientation)
+	}
+}
+
+func Test_remoteWD_SetOrientation(t *testing.T) {
+	setup(t)
+
+	var err error
+	err = driver.SetOrientation(OrientationLandscapeLeft)
+	err = driver.SetOrientation(OrientationLandscapeRight)
+	err = driver.SetOrientation(OrientationPortrait)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_Rotation(t *testing.T) {
+	setup(t)
+
+	rotation, err := driver.Rotation()
+	if err != nil {
+		t.Fatal()
+	}
+	t.Log(rotation)
+}
+
+func Test_remoteWD_SetRotation(t *testing.T) {
+	setup(t)
+
+	err := driver.SetRotation(Rotation{X: 0, Y: 0, Z: 270})
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_PerformW3CActions(t *testing.T) {
+	// setup(t)
+	// actions := NewW3CActions().SendKeys("App Store")
+
+	element := setupElement(t, BySelector{Name: "touchableView"})
+	actions := NewW3CActions().FingerAction(
+		NewFingerAction().
+			Move(NewFingerMove().WithXY(-15, -85).WithOrigin(element)).
+			Down().
+			Pause(0.25).
+			Move(NewFingerMove().WithOrigin(element)).
+			Pause(0.25).
+			Up(),
+		NewFingerAction().
+			Move(NewFingerMove().WithXY(15, 85).WithOrigin(element)).
+			Down().
+			Pause(0.25).
+			Move(NewFingerMove().WithOrigin(element)).
+			Pause(0.25).
+			Up(),
+	)
+	err := driver.PerformW3CActions(actions)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_PerformAppiumTouchActions(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	actions := NewTouchActions().
+		Press(NewTouchActionPress().WithElement(element).WithXY(100, 150).WithPressure(0.2)).
+		Wait(0.2).
+		MoveTo(NewTouchActionMoveTo().WithXY(300, 150)).
+		Wait(0.2).
+		MoveTo(NewTouchActionMoveTo().WithElement(element)).
+		Wait(0.2).
+		MoveTo(NewTouchActionMoveTo().WithElement(element).WithXY(300, 400)).
+		Release()
+
+	err := driver.PerformAppiumTouchActions(actions)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_ActiveElement(t *testing.T) {
+	setup(t)
+
+	element, err := driver.ActiveElement()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = element
+	// t.Log(element)
+}
+
+func Test_remoteWD_FindElement(t *testing.T) {
+	setup(t)
+
+	element, err := driver.FindElement(BySelector{Name: "设置"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = element
+	// t.Log(element)
+}
+
+func Test_remoteWD_FindElements(t *testing.T) {
+	setup(t)
+
+	elements, err := driver.FindElements(BySelector{ClassName: ElementType{Icon: true}})
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = elements
+	t.Log(elements)
+}
+
+func Test_remoteWD_Screenshot(t *testing.T) {
+	setup(t)
+
+	screenshot, err := driver.Screenshot()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = screenshot
+
+	// img, format, err := image.Decode(screenshot)
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+	// userHomeDir, _ := os.UserHomeDir()
+	// file, err := os.Create(userHomeDir + "/Desktop/s1." + format)
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+	// defer func() { _ = file.Close() }()
+	// switch format {
+	// case "png":
+	// 	err = png.Encode(file, img)
+	// case "jpeg":
+	// 	err = jpeg.Encode(file, img, nil)
+	// }
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+	// t.Log(file.Name())
+}
+
+func Test_remoteWD_Source(t *testing.T) {
+	setup(t)
+
+	var source string
+	var err error
+
+	// source, err = driver.Source()
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+
+	source, err = driver.Source(NewSourceOption().WithScope("AppiumAUT"))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// source, err = driver.Source(NewSourceOption().WithFormatAsJson())
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+
+	// source, err = driver.Source(NewSourceOption().WithFormatAsDescription())
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+
+	// source, err = driver.Source(NewSourceOption().WithFormatAsXml().WithExcludedAttributes([]string{"label", "type", "index"}))
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+
+	_ = source
+	fmt.Println(source)
+}
+
+func Test_remoteWD_AccessibleSource(t *testing.T) {
+	setup(t)
+
+	source, err := driver.AccessibleSource()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = source
+	fmt.Println(source)
+}
+
+func Test_remoteWD_Wait(t *testing.T) {
+	setup(t)
+
+	var element WebElement
+	var err error
+
+	by := BySelector{Name: "通知"}
+	// driver.AppLaunch()
+	exists := func(d WebDriver) (bool, error) {
+		element, err = d.FindElement(by)
+		if err == nil {
+			return true, nil
+		}
+		return false, nil
+	}
+	_ = exists
+	_ = element
+
+	err = driver.AppLaunchUnattached(bundleId)
+	if err != nil {
+		t.Fatal(err)
+	}
+	// element, err = driver.FindElement(by)
+	err = driver.WaitWithTimeoutAndInterval(exists, time.Second*10, time.Millisecond*10)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// t.Log(element.Rect())
+}
+
+func Test_remoteWD_Location(t *testing.T) {
+	setup(t)
+
+	location, err := driver.Location()
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(location)
+}
+
+func Test_remoteWD_KeyboardDismiss(t *testing.T) {
+	setup(t)
+
+	err := driver.KeyboardDismiss()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWD_ExpectNotification(t *testing.T) {
+	setup(t)
+
+	// bundleId = "com.apple.shortcuts"
+	// err := driver.ExpectNotification("shortcuts", NotificationTypePlain, 10)
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+}
+
+func Test_remoteWD_IOHIDEvent(t *testing.T) {
+	setup(t)
+
+	err := driver.IOHIDEvent(EventPageIDConsumer, EventUsageIDCsmrVolumeDown)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func setupElement(t *testing.T, by BySelector) WebElement {
+	setup(t)
+	element, err := driver.FindElement(by)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return element
+}
+
+func Test_remoteWE_Click(t *testing.T) {
+	element := setupElement(t, BySelector{LinkText: NewElementAttribute().WithLabel("设置")})
+
+	err := element.Click()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_SendKeys(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{SearchField: true}})
+
+	err := element.SendKeys("App Store")
+	// err := element.SendKeys("App Store", 3)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_Clear(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{SearchField: true}})
+
+	err := element.Clear()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_Tap(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	err := element.Tap(10, 20)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_DoubleTap(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	err := element.DoubleTap()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_TouchAndHold(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	err := element.TouchAndHold(-1)
+	// err := element.TouchAndHold(5)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_TwoFingerTap(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	err := element.TwoFingerTap()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_TapWithNumberOfTaps(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	err := element.TapWithNumberOfTaps(3, 3)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_ForceTouch(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	// err := element.ForceTouch(1, -1)
+	err := element.ForceTouchFloat(10, 20, 1, -1)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_Drag(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	// err := element.Drag(10, 20, 10, 300, -1)
+	err := element.Swipe(10, 20, 10, 300)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_SwipeDirection(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	// err := element.SwipeDirection(DirectionUp, -1)
+	err := element.SwipeDirection(DirectionDown, 120)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_Pinch(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	// zoom in
+	// err := element.Pinch(2,10)
+	// zoom out
+	err := element.Pinch(0.9, -4.5)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_PinchToZoomOutByW3CAction(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	err := element.PinchToZoomOutByW3CAction(15)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_Rotate(t *testing.T) {
+	element := setupElement(t, BySelector{Name: "touchableView"})
+
+	// 90 CW
+	// err := element.Rotate(math.Pi / 2)
+	// 180 CCW
+	err := element.Rotate(math.Pi * -2)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_PickerWheelSelect(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{PickerWheel: true}})
+
+	err := element.PickerWheelSelect(PickerWheelOrderNext, 3)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = element.PickerWheelSelect(PickerWheelOrderPrevious)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_scroll(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{Table: true}})
+
+	var err error
+	// err = element.ScrollElementByName("电池")
+	// err = element.ScrollElementByPredicate("type == 'XCUIElementTypeCell' AND name LIKE 'Safari*'")
+	err = element.ScrollDirection(DirectionDown, 0.8)
+
+	// element, err = driver.FindElement(BySelector{PartialLinkText: NewElementAttribute().WithLabel("Safari")})
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+	// err = element.ScrollToVisible()
+
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_FindElement(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{Table: true}})
+
+	var err error
+	element, err = element.FindElement(BySelector{PartialLinkText: NewElementAttribute().WithLabel("Safari")})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = element.Click()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_FindElements(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{Table: true}})
+
+	elements, err := element.FindElements(BySelector{ClassName: ElementType{Cell: true}})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = elements[0].Click()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_FindVisibleCells(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{Table: true}})
+
+	cells, err := element.FindVisibleCells()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = cells[0].Click()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func Test_remoteWE_Rect(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{Switch: true}})
+
+	rect, err := element.Rect()
+	if err != nil {
+		t.Fatal(err)
+	}
+	location, err := element.Location()
+	if err != nil {
+		t.Fatal(err)
+	}
+	size, err := element.Size()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_, _, _ = rect, location, size
+	t.Log(rect, location, size)
+}
+
+func Test_remoteWE_Text(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{Switch: true}})
+
+	text, err := element.Text()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = text
+	// t.Log(text)
+}
+
+func Test_remoteWE_Type(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{Switch: true}})
+
+	elemType, err := element.Type()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = elemType
+	// t.Log(elemType)
+}
+
+func Test_remoteWE_IsEnabled(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{Switch: true}})
+
+	enabled, err := element.IsEnabled()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = enabled
+	// t.Log(enabled)
+}
+
+func Test_remoteWE_IsDisplayed(t *testing.T) {
+	element := setupElement(t, BySelector{PartialLinkText: NewElementAttribute().WithLabel("Safari")})
+
+	displayed, err := element.IsDisplayed()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = displayed
+	// t.Log(displayed)
+}
+
+func Test_remoteWE_IsSelected(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{Switch: true}})
+	// element := setupElement(t, BySelector{Name: "添加到主屏幕"})
+	// element := setupElement(t, BySelector{Name: "仅App资源库"})
+
+	selected, err := element.IsSelected()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = selected
+	// t.Log(selected)
+}
+
+func Test_remoteWE_IsAccessible(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{Switch: true}})
+
+	accessible, err := element.IsAccessible()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = accessible
+	// t.Log(accessible)
+}
+
+func Test_remoteWE_IsAccessibilityContainer(t *testing.T) {
+	// element := setupElement(t, BySelector{ClassName: ElementType{Switch: true}})
+	element := setupElement(t, BySelector{ClassName: ElementType{Table: true}})
+
+	isAccessibilityContainer, err := element.IsAccessibilityContainer()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = isAccessibilityContainer
+	// t.Log(isAccessibilityContainer)
+}
+
+func Test_remoteWE_GetAttribute(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{StaticText: true}})
+
+	value, err := element.GetAttribute(NewElementAttribute().WithValue(""))
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = value
+	// t.Log(value)
+}
+
+func Test_remoteWE_Screenshot(t *testing.T) {
+	element := setupElement(t, BySelector{ClassName: ElementType{TextView: true}})
+
+	screenshot, err := element.Screenshot()
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = screenshot
+
+	// img, format, err := image.Decode(screenshot)
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+	// userHomeDir, _ := os.UserHomeDir()
+	// file, err := os.Create(userHomeDir + "/Desktop/e1." + format)
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+	// defer func() { _ = file.Close() }()
+	// switch format {
+	// case "png":
+	// 	err = png.Encode(file, img)
+	// case "jpeg":
+	// 	err = jpeg.Encode(file, img, nil)
+	// }
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+	// t.Log(file.Name())
+}
diff --git a/hrp/pkg/uixt/ocr_test.go b/hrp/pkg/uixt/ocr_test.go
new file mode 100644
index 000000000..da868c27a
--- /dev/null
+++ b/hrp/pkg/uixt/ocr_test.go
@@ -0,0 +1,18 @@
+//go:build ocr
+
+package uixt
+
+import (
+	"testing"
+)
+
+func TestDriverExtOCR(t *testing.T) {
+	driverExt, err := iosDevice.NewDriver(nil)
+	checkErr(t, err)
+
+	x, y, width, height, err := driverExt.FindTextByOCR("抖音")
+	checkErr(t, err)
+
+	t.Logf("x: %v, y: %v, width: %v, height: %v", x, y, width, height)
+	driverExt.Driver.TapFloat(x+width*0.5, y+height*0.5-20)
+}
diff --git a/hrp/pkg/uixt/swipe_test.go b/hrp/pkg/uixt/swipe_test.go
new file mode 100644
index 000000000..22950c44b
--- /dev/null
+++ b/hrp/pkg/uixt/swipe_test.go
@@ -0,0 +1,48 @@
+//go:build localtest
+
+package uixt
+
+import (
+	"testing"
+)
+
+func TestSwipeUntil(t *testing.T) {
+	driverExt, err := iosDevice.NewDriver(nil)
+	checkErr(t, err)
+
+	var point PointF
+	findApp := func(d *DriverExt) error {
+		var err error
+		point, err = d.GetTextXY("抖音")
+		return err
+	}
+	foundAppAction := func(d *DriverExt) error {
+		// click app, launch douyin
+		return d.TapAbsXY(point.X, point.Y)
+	}
+
+	driverExt.Driver.Homescreen()
+
+	// swipe to first screen
+	for i := 0; i < 5; i++ {
+		driverExt.SwipeRight()
+	}
+
+	// swipe until app found
+	err = driverExt.SwipeUntil("left", findApp, foundAppAction, WithDataMaxRetryTimes(10))
+	checkErr(t, err)
+
+	findLive := func(d *DriverExt) error {
+		var err error
+		point, err = d.GetTextXY("点击进入直播间")
+		return err
+	}
+	foundLiveAction := func(d *DriverExt) error {
+		// enter live room
+		return d.TapAbsXY(point.X, point.Y)
+	}
+
+	// swipe until live room found
+	err = driverExt.SwipeUntil("up", findLive, foundLiveAction, WithDataMaxRetryTimes(20))
+	checkErr(t, err)
+}
diff --git a/hrp/pkg/uixt/tap_test.go b/hrp/pkg/uixt/tap_test.go
new file mode 100644
index 000000000..c53659988
--- /dev/null
+++ b/hrp/pkg/uixt/tap_test.go
@@ -0,0 +1,51 @@
+//go:build localtest
+
+package uixt
+
+import (
+	"testing"
+)
+
+var iosDevice *IOSDevice
+
+func init() {
+	iosDevice, _ = NewIOSDevice()
+}
+
+func TestDriverExt_TapWithNumber(t *testing.T) {
+	driverExt, err := iosDevice.NewDriver(nil)
+	checkErr(t, err)
+
+	pathSearch := "/Users/hero/Documents/temp/2020-05/opencv/flag7.png"
+
+	err = driverExt.TapWithNumber(pathSearch, 3)
+	checkErr(t, err)
+
+	err = driverExt.TapWithNumberOffset(pathSearch, 3, 0.5, 0.75)
+	checkErr(t, err)
+}
+
+func TestDriverExt_TapXY(t *testing.T) {
+	driverExt, err := iosDevice.NewDriver(nil)
+	checkErr(t, err)
+
+	err = driverExt.TapXY(0.4, 0.5)
+	checkErr(t, err)
+}
+
+func TestDriverExt_TapAbsXY(t *testing.T) {
+	driverExt, err := iosDevice.NewDriver(nil)
+	checkErr(t, err)
+
+	err = driverExt.TapAbsXY(100, 300)
+	checkErr(t, err)
+}
+
+func TestDriverExt_TapWithOCR(t *testing.T) {
+	driverExt, err := iosDevice.NewDriver(nil)
+	checkErr(t, err)
+
+	// 需要点击文字上方的图标
+	err = driverExt.TapOffset("抖音", 0.5, -1)
+	checkErr(t, err)
+}
diff --git a/hrp/pkg/uixt/touch_test.go b/hrp/pkg/uixt/touch_test.go
new file mode 100644
index 000000000..aa5515a8c
--- /dev/null
+++ b/hrp/pkg/uixt/touch_test.go
@@ -0,0 +1,39 @@
+//go:build localtest
+
+package uixt
+
+import (
+	"testing"
+)
+
+func TestDriverExt_ForceTouch(t *testing.T) {
+	driverExt, err := iosDevice.NewDriver(nil)
+	checkErr(t, err)
+
+	pathSearch := "/Users/hero/Documents/temp/2020-05/opencv/IMG_ft.png"
+
+	err = driverExt.ForceTouch(pathSearch, 0.5, 3)
+	checkErr(t, err)
+
+	// err = driverExt.ForceTouchOffset(pathSearch, 0.5, 0.1, 0.9)
+	// checkErr(t, err)
+
+	// err = driverExt.ForceTouchOffset(pathSearch, 0.2, 1.1, -1)
+	// checkErr(t, err)
+}
+
+func TestDriverExt_TouchAndHold(t *testing.T) {
+	driverExt, err := iosDevice.NewDriver(nil)
+	checkErr(t, err)
+
+	pathSearch := "/Users/hero/Documents/temp/2020-05/opencv/IMG_ft.png"
+
+	// err = driverExt.TouchAndHold(pathSearch)
+	// checkErr(t, err)
+
+	// err = driverExt.TouchAndHold(pathSearch, 3)
+	// checkErr(t, err)
+
+	err = driverExt.TouchAndHoldOffset(pathSearch, 0.8, 0.1)
+	checkErr(t, err)
+}
diff --git a/hrp/runner_test.go b/hrp/runner_test.go
index 383cae09d..c07cf1f15 100644
--- a/hrp/runner_test.go
+++ b/hrp/runner_test.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
 	"github.com/rs/zerolog/log"
 	"github.com/stretchr/testify/assert"
 )
@@ -16,7 +17,7 @@ func buildHashicorpGoPlugin() {
 	err := BuildPlugin(tmpl("plugin/debugtalk.go"), tmpl("debugtalk.bin"))
 	if err != nil {
 		log.Error().Err(err).Msg("build hashicorp go plugin failed")
-		os.Exit(1)
+		os.Exit(code.GetErrorCode(err))
 	}
 }
 
@@ -33,7 +34,7 @@ func buildHashicorpPyPlugin() {
 	err := ioutil.WriteFile(tmpl("debugtalk.py"), src, 0o644)
 	if err != nil {
 		log.Error().Err(err).Msg("copy hashicorp python plugin failed")
-		os.Exit(1)
+		os.Exit(code.GetErrorCode(err))
 	}
 }
 
diff --git a/hrp/step_mobile_ui_test.go b/hrp/step_mobile_ui_test.go
new file mode 100644
index 000000000..c9c7dae30
--- /dev/null
+++ b/hrp/step_mobile_ui_test.go
@@ -0,0 +1,183 @@
+//go:build localtest
+
+package hrp
+
+import (
+	"testing"
+)
+
+func TestIOSSettingsAction(t *testing.T) {
+	testCase := &TestCase{
+		Config: NewConfig("ios ui action on Settings").
+			SetIOS(WithWDAPort(8700), WithWDAMjpegPort(8800)),
+		TestSteps: []IStep{
+			NewStep("launch Settings").
+				IOS().Home().Tap("设置").
+				Validate().
+				AssertNameExists("飞行模式").
+				AssertLabelExists("蓝牙").
+				AssertOCRExists("个人热点"),
+			NewStep("swipe up and down").
+				IOS().SwipeUp().SwipeUp().SwipeDown(),
+		},
+	}
+	err := NewRunner(t).Run(testCase)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestIOSSearchApp(t *testing.T) {
+	testCase := &TestCase{
+		Config: NewConfig("ios ui action on Search App 资源库"),
+		TestSteps: []IStep{
+			NewStep("进入 App 资源库 搜索框").
+				IOS().Home().SwipeLeft().Times(2).Tap("dewey-search-field").
+				Validate().
+				AssertLabelExists("取消"),
+			NewStep("搜索抖音").
+				IOS().Input("抖音\n"),
+		},
+	}
+	err := NewRunner(t).Run(testCase)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestIOSAppLaunch(t *testing.T) {
+	testCase := &TestCase{
+		Config: NewConfig("启动 & 关闭 App").
+			SetIOS(WithWDAPort(8700), WithWDAMjpegPort(8800)),
+		TestSteps: []IStep{
+			NewStep("终止今日头条").
+				IOS().AppTerminate("com.ss.iphone.article.News"),
+			NewStep("启动今日头条").
+				IOS().AppLaunch("com.ss.iphone.article.News"),
+			NewStep("终止今日头条").
+				IOS().AppTerminate("com.ss.iphone.article.News"),
+			NewStep("启动今日头条").
+				IOS().AppLaunchUnattached("com.ss.iphone.article.News"),
+		},
+	}
+	err := NewRunner(t).Run(testCase)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestIOSWeixinLive(t *testing.T) {
+	testCase := &TestCase{
+		Config: NewConfig("ios ui action on 微信直播").
+			SetIOS(WithLogOn(true), WithWDAPort(8100), WithWDAMjpegPort(9100)),
+		TestSteps: []IStep{
+			NewStep("启动微信").
+				IOS().
+				Home().
+				AppTerminate("com.tencent.xin"). // 关闭已运行的微信，确保启动微信后在「微信」首页
+				Tap("微信").
+				Validate().
+				AssertLabelExists("通讯录", "微信启动失败，「通讯录」不存在"),
+			NewStep("进入直播页").
+				IOS().
+				Tap("发现").Sleep(5). // 进入「发现页」；等待 5 秒确保加载完成
+				TapByOCR("直播").     // 通过 OCR 识别「直播」
+				Validate().
+				AssertLabelExists("直播"),
+			NewStep("向上滑动 5 次").
+				IOS().
+				SwipeUp().Times(3).ScreenShot(). // 上划 3 次，截图保存
+				SwipeUp().Times(2).ScreenShot(), // 再上划 2 次，截图保存
+		},
+	}
+	err := NewRunner(t).Run(testCase)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestIOSCameraPhotoCapture(t *testing.T) {
+	testCase := &TestCase{
+		Config: NewConfig("ios camera photo capture"),
+		TestSteps: []IStep{
+			NewStep("launch camera").
+				IOS().Home().
+				StopCamera().
+				StartCamera().
+				Validate().
+				AssertLabelExists("PhotoCapture", "拍照按钮不存在"),
+			NewStep("start recording").
+				IOS().Tap("PhotoCapture"),
+		},
+	}
+	err := NewRunner(t).Run(testCase)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestIOSCameraVideoCapture(t *testing.T) {
+	testCase := &TestCase{
+		Config: NewConfig("ios camera video capture"),
+		TestSteps: []IStep{
+			NewStep("launch camera").
+				IOS().Home().
+				StopCamera().
+				StartCamera().
+				Validate().
+				AssertLabelExists("PhotoCapture", "录像按钮不存在"),
+			NewStep("switch to video capture").
+				IOS().
+				SwipeRight().
+				Validate().
+				AssertLabelExists("VideoCapture", "拍摄按钮不存在"),
+			NewStep("start recording").
+				IOS().
+				Tap("VideoCapture"). // 开始录像
+				Sleep(5).
+				Tap("VideoCapture"), // 停止录像
+		},
+	}
+	err := NewRunner(t).Run(testCase)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestIOSDouyinAction(t *testing.T) {
+	testCase := &TestCase{
+		Config: NewConfig("ios ui action on 抖音"),
+		TestSteps: []IStep{
+			NewStep("launch douyin").
+				IOS().Home().Tap("//*[@label='抖音']").
+				Validate().
+				AssertLabelExists("首页", "首页 tab 不存在").
+				AssertLabelExists("消息", "消息 tab 不存在"),
+			NewStep("swipe up and down").
+				IOS().SwipeUp().Times(3).SwipeDown(),
+		},
+	}
+	err := NewRunner(t).Run(testCase)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestAndroidAction(t *testing.T) {
+	testCase := &TestCase{
+		Config: NewConfig("android ui action"),
+		TestSteps: []IStep{
+			NewStep("launch douyin").
+				Android().Serial("xxx").Tap("抖音").
+				Validate().
+				AssertNameExists("首页", "首页 tab 不存在").
+				AssertNameExists("消息", "消息 tab 不存在"),
+			NewStep("swipe up and down").
+				Android().Serial("xxx").SwipeUp().SwipeUp().SwipeDown(),
+		},
+	}
+	err := NewRunner(t).Run(testCase)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/hrp/step_request_test.go b/hrp/step_request_test.go
index 338d7e7e6..3f7e63c59 100644
--- a/hrp/step_request_test.go
+++ b/hrp/step_request_test.go
@@ -77,33 +77,17 @@ func TestRunRequestPostDataToStruct(t *testing.T) {
 	}
 }
 
-func TestRunRequestRun(t *testing.T) {
-	testcase := &TestCase{
-		Config:    NewConfig("test").SetBaseURL("https://postman-echo.com"),
-		TestSteps: []IStep{stepGET, stepPOSTData},
-	}
-	runner := NewRunner(t).SetRequestsLogOn()
-	sessionRunner, _ := runner.NewSessionRunner(testcase)
-
-	if _, err := stepGET.Run(sessionRunner); err != nil {
-		t.Fatalf("stepGET.Run() error: %v", err)
-	}
-	if _, err := stepPOSTData.Run(sessionRunner); err != nil {
-		t.Fatalf("stepPOSTData.Run() error: %v", err)
-	}
-}
-
 func TestRunRequestStatOn(t *testing.T) {
 	testcase := &TestCase{
 		Config:    NewConfig("test").SetBaseURL("https://postman-echo.com"),
 		TestSteps: []IStep{stepGET, stepPOSTData},
 	}
-	runner := NewRunner(t).SetHTTPStatOn()
-	sessionRunner, _ := runner.NewSessionRunner(testcase)
+	caseRunner, _ := NewRunner(t).SetHTTPStatOn().NewCaseRunner(testcase)
+	sessionRunner := caseRunner.NewSession()
 	if err := sessionRunner.Start(nil); err != nil {
 		t.Fatal()
 	}
-	summary := sessionRunner.GetSummary()
+	summary, _ := sessionRunner.GetSummary()
 
 	stat := summary.Records[0].HttpStat
 	if !assert.GreaterOrEqual(t, stat["DNSLookup"], int64(0)) {
diff --git a/hrp/step_testcase.go b/hrp/step_testcase.go
index 09440e5a6..1464735e6 100644
--- a/hrp/step_testcase.go
+++ b/hrp/step_testcase.go
@@ -5,6 +5,7 @@ import (
 	"time"
 
 	"github.com/jinzhu/copier"
+	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
 )
 
@@ -51,18 +52,20 @@ func (s *StepTestCaseWithOptionalArgs) Run(r *SessionRunner) (stepResult *StepRe
 		Success:  false,
 	}
 
+	// merge step variables with session variables
+	stepVariables, err := r.ParseStepVariables(s.step.Variables)
+	if err != nil {
+		err = errors.Wrap(err, "parse step variables failed")
+		return
+	}
+
 	defer func() {
 		// update testcase summary
 		if err != nil {
-			stepResult.Attachment = err.Error()
+			stepResult.Attachments = err.Error()
 		}
 	}()
 
-	stepVariables, err := r.MergeStepVariables(s.step.Variables)
-	if err != nil {
-		return stepResult, err
-	}
-
 	stepTestCase := s.step.TestCase.(*TestCase)
 
 	// copy testcase to avoid data racing
@@ -80,20 +83,27 @@ func (s *StepTestCaseWithOptionalArgs) Run(r *SessionRunner) (stepResult *StepRe
 	// merge & override extractors
 	copiedTestCase.Config.Export = mergeSlices(s.step.Export, copiedTestCase.Config.Export)
 
-	sessionRunner, err := r.hrpRunner.NewSessionRunner(copiedTestCase)
+	caseRunner, err := r.caseRunner.hrpRunner.NewCaseRunner(copiedTestCase)
 	if err != nil {
-		log.Error().Err(err).Msg("create session runner failed")
+		log.Error().Err(err).Msg("create case runner failed")
 		return stepResult, err
 	}
+	sessionRunner := caseRunner.NewSession()
 
 	start := time.Now()
 	// run referenced testcase with step variables
 	err = sessionRunner.Start(stepVariables)
-	if err == nil {
-		stepResult.Success = true
-	}
 	stepResult.Elapsed = time.Since(start).Milliseconds()
-	summary := sessionRunner.GetSummary()
+
+	summary, err2 := sessionRunner.GetSummary()
+	if err2 != nil {
+		log.Error().Err(err).Msg("get summary failed")
+		if err != nil {
+			err = errors.Wrap(err, err2.Error())
+		} else {
+			err = err2
+		}
+	}
 	// update step names
 	for _, record := range summary.Records {
 		record.Name = fmt.Sprintf("%s - %s", stepResult.Name, record.Name)
@@ -102,11 +112,8 @@ func (s *StepTestCaseWithOptionalArgs) Run(r *SessionRunner) (stepResult *StepRe
 	// export testcase export variables
 	stepResult.ExportVars = summary.InOut.ExportVars
 
-	// merge testcase summary
-	r.summary.Records = append(r.summary.Records, summary.Records...)
-	r.summary.Stat.Total += summary.Stat.Total
-	r.summary.Stat.Successes += summary.Stat.Successes
-	r.summary.Stat.Failures += summary.Stat.Failures
-
+	if err == nil {
+		stepResult.Success = true
+	}
 	return stepResult, err
 }
diff --git a/hrp/summary_test.go b/hrp/summary_test.go
index 32343e0e0..034ded167 100644
--- a/hrp/summary_test.go
+++ b/hrp/summary_test.go
@@ -12,7 +12,7 @@ func TestGenHTMLReport(t *testing.T) {
 		StepType:    stepTypeRequest,
 		Success:     false,
 		ContentSize: 0,
-		Attachment:  "err",
+		Attachments: "err",
 	}
 	caseSummary1.Records = []*StepResult{stepResult1, stepResult2, nil}
 	summary.appendCaseSummary(caseSummary1)
diff --git a/hrp/testcase.go b/hrp/testcase.go
index e1cd45336..67d0d7157 100644
--- a/hrp/testcase.go
+++ b/hrp/testcase.go
@@ -2,16 +2,15 @@ package hrp
 
 import (
 	"fmt"
-	"io/fs"
-	"os"
 	"path/filepath"
 	"strings"
 
+	"github.com/mitchellh/mapstructure"
 	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
 
 	"github.com/httprunner/httprunner/v4/hrp/internal/builtin"
-	"github.com/mitchellh/mapstructure"
+	"github.com/httprunner/httprunner/v4/hrp/internal/code"
 )
 
 // ITestCase represents interface for testcases,
@@ -51,6 +50,24 @@ func (tc *TestCase) ToTCase() *TCase {
 	return tCase
 }
 
+func (tc *TestCase) Dump2JSON(targetPath string) error {
+	tCase := tc.ToTCase()
+	err := builtin.Dump2JSON(tCase, targetPath)
+	if err != nil {
+		return errors.Wrap(err, "dump testcase to json failed")
+	}
+	return nil
+}
+
+func (tc *TestCase) Dump2YAML(targetPath string) error {
+	tCase := tc.ToTCase()
+	err := builtin.Dump2YAML(tCase, targetPath)
+	if err != nil {
+		return errors.Wrap(err, "dump testcase to yaml failed")
+	}
+	return nil
+}
+
 // TestCasePath implements ITestCase interface.
 type TestCasePath string
 
@@ -101,7 +118,8 @@ func (tc *TCase) MakeCompat() (err error) {
 
 func (tc *TCase) ToTestCase(casePath string) (*TestCase, error) {
 	if tc.TestSteps == nil {
-		return nil, errors.New("invalid testcase format, missing teststeps!")
+		return nil, errors.Wrap(code.InvalidCaseFormat,
+			"invalid testcase format, missing teststeps!")
 	}
 
 	if tc.Config == nil {
@@ -153,7 +171,8 @@ func (tc *TCase) toTestCase() (*TestCase, error) {
 			if ok {
 				path := filepath.Join(projectRootDir, apiPath)
 				if !builtin.IsFilePathExists(path) {
-					return nil, errors.New("referenced api file not found: " + path)
+					return nil, errors.Wrap(code.ReferencedFileNotFound,
+						fmt.Sprintf("referenced api file not found: %s", path))
 				}
 
 				refAPI := APIPath(path)
@@ -165,7 +184,8 @@ func (tc *TCase) toTestCase() (*TestCase, error) {
 			} else {
 				apiMap, ok := step.API.(map[string]interface{})
 				if !ok {
-					return nil, fmt.Errorf("referenced api should be map or path(string), got %v", step.API)
+					return nil, errors.Wrap(code.InvalidCaseFormat,
+						fmt.Sprintf("referenced api should be map or path(string), got %v", step.API))
 				}
 				api := &API{}
 				err = mapstructure.Decode(apiMap, api)
@@ -176,7 +196,8 @@ func (tc *TCase) toTestCase() (*TestCase, error) {
 			}
 			_, ok = step.API.(*API)
 			if !ok {
-				return nil, fmt.Errorf("failed to handle referenced API, got %v", step.TestCase)
+				return nil, errors.Wrap(code.InvalidCaseFormat,
+					fmt.Sprintf("failed to handle referenced API, got %v", step.TestCase))
 			}
 			testCase.TestSteps = append(testCase.TestSteps, &StepAPIWithOptionalArgs{
 				step: step,
@@ -186,7 +207,8 @@ func (tc *TCase) toTestCase() (*TestCase, error) {
 			if ok {
 				path := filepath.Join(projectRootDir, casePath)
 				if !builtin.IsFilePathExists(path) {
-					return nil, errors.New("referenced testcase file not found: " + path)
+					return nil, errors.Wrap(code.ReferencedFileNotFound,
+						fmt.Sprintf("referenced testcase file not found: %s", path))
 				}
 
 				refTestCase := TestCasePath(path)
@@ -198,7 +220,8 @@ func (tc *TCase) toTestCase() (*TestCase, error) {
 			} else {
 				testCaseMap, ok := step.TestCase.(map[string]interface{})
 				if !ok {
-					return nil, fmt.Errorf("referenced testcase should be map or path(string), got %v", step.TestCase)
+					return nil, errors.Wrap(code.InvalidCaseFormat,
+						fmt.Sprintf("referenced testcase should be map or path(string), got %v", step.TestCase))
 				}
 				tCase := &TCase{}
 				err = mapstructure.Decode(testCaseMap, tCase)
@@ -213,7 +236,8 @@ func (tc *TCase) toTestCase() (*TestCase, error) {
 			}
 			_, ok = step.TestCase.(*TestCase)
 			if !ok {
-				return nil, fmt.Errorf("failed to handle referenced testcase, got %v", step.TestCase)
+				return nil, errors.Wrap(code.InvalidCaseFormat,
+					fmt.Sprintf("failed to handle referenced testcase, got %v", step.TestCase))
 			}
 			testCase.TestSteps = append(testCase.TestSteps, &StepTestCaseWithOptionalArgs{
 				step: step,
@@ -242,6 +266,14 @@ func (tc *TCase) toTestCase() (*TestCase, error) {
 			testCase.TestSteps = append(testCase.TestSteps, &StepWebSocket{
 				step: step,
 			})
+		} else if step.IOS != nil {
+			testCase.TestSteps = append(testCase.TestSteps, &StepMobile{
+				step: step,
+			})
+		} else if step.Android != nil {
+			testCase.TestSteps = append(testCase.TestSteps, &StepMobile{
+				step: step,
+			})
 		} else {
 			log.Warn().Interface("step", step).Msg("[convertTestCase] unexpected step")
 		}
@@ -293,7 +325,8 @@ func convertCompatValidator(Validators []interface{}) (err error) {
 			for assertMethod, iValidatorContent := range validatorMap {
 				validatorContent := iValidatorContent.([]interface{})
 				if len(validatorContent) > 3 {
-					return fmt.Errorf("unexpected validator format: %v", validatorMap)
+					return errors.Wrap(code.InvalidCaseFormat,
+						fmt.Sprintf("unexpected validator format: %v", validatorMap))
 				}
 				validator.Check = validatorContent[0].(string)
 				validator.Assert = assertMethod
@@ -306,7 +339,8 @@ func convertCompatValidator(Validators []interface{}) (err error) {
 			Validators[i] = validator
 			continue
 		}
-		return fmt.Errorf("unexpected validator format: %v", validatorMap)
+		return errors.Wrap(code.InvalidCaseFormat,
+			fmt.Sprintf("unexpected validator format: %v", validatorMap))
 	}
 	return nil
 }
@@ -333,61 +367,3 @@ func convertJmespathExpr(checkExpr string) string {
 	}
 	return strings.Join(checkItems, ".")
 }
-
-func LoadTestCases(iTestCases ...ITestCase) ([]*TestCase, error) {
-	testCases := make([]*TestCase, 0)
-
-	for _, iTestCase := range iTestCases {
-		if _, ok := iTestCase.(*TestCase); ok {
-			testcase, err := iTestCase.ToTestCase()
-			if err != nil {
-				log.Error().Err(err).Msg("failed to convert ITestCase interface to TestCase struct")
-				return nil, err
-			}
-			testCases = append(testCases, testcase)
-			continue
-		}
-
-		// iTestCase should be a TestCasePath, file path or folder path
-		tcPath, ok := iTestCase.(*TestCasePath)
-		if !ok {
-			return nil, errors.New("invalid iTestCase type")
-		}
-
-		casePath := tcPath.GetPath()
-		err := fs.WalkDir(os.DirFS(casePath), ".", func(path string, dir fs.DirEntry, e error) error {
-			if dir == nil {
-				// casePath is a file other than a dir
-				path = casePath
-			} else if dir.IsDir() && path != "." && strings.HasPrefix(path, ".") {
-				// skip hidden folders
-				return fs.SkipDir
-			} else {
-				// casePath is a dir
-				path = filepath.Join(casePath, path)
-			}
-
-			// ignore non-testcase files
-			ext := filepath.Ext(path)
-			if ext != ".yml" && ext != ".yaml" && ext != ".json" {
-				return nil
-			}
-
-			// filtered testcases
-			testCasePath := TestCasePath(path)
-			tc, err := testCasePath.ToTestCase()
-			if err != nil {
-				log.Warn().Err(err).Str("path", path).Msg("load testcase failed")
-				return nil
-			}
-			testCases = append(testCases, tc)
-			return nil
-		})
-		if err != nil {
-			return nil, errors.Wrap(err, "read dir failed")
-		}
-	}
-
-	log.Info().Int("count", len(testCases)).Msg("load testcases successfully")
-	return testCases, nil
-}