diff --git a/.github/workflows/hrp-scaffold.yml b/.github/workflows/hrp-scaffold.yml index 872dcd6a..e513435a 100644 --- a/.github/workflows/hrp-scaffold.yml +++ b/.github/workflows/hrp-scaffold.yml @@ -4,6 +4,8 @@ on: push: branches: - master + - v2 + - v3 pull_request: env: @@ -32,10 +34,10 @@ jobs: - name: Run generated demo tests run: ./output/hrp run demo/testcases/ - name: Run API test demo in examples - run: ./output/hrp run examples/demo-with-py-plugin/testcases/demo_with_funplugin.json + run: ./output/hrp run examples/demo-with-py-plugin/testcases/demo.json - name: Run load test demo in examples run: | - ./output/hrp boom examples/demo-with-py-plugin/testcases/demo_with_funplugin.json --spawn-count 10 --spawn-rate 10 --loop-count 10 + ./output/hrp boom examples/demo-with-py-plugin/testcases/demo.json --spawn-count 10 --spawn-rate 10 --loop-count 10 scaffold-with-go-plugin: strategy: @@ -56,16 +58,18 @@ jobs: run: make build - name: Run start project run: ./output/hrp startproject demo --go + - name: Build plugin + run: ./output/hrp build -o demo/debugtalk.bin demo/plugin/debugtalk.go - name: Run generated demo tests run: ./output/hrp run demo/testcases/ - name: Run API test demo in examples run: | - go build -o examples/demo-with-go-plugin/debugtalk.bin examples/demo-with-go-plugin/plugin/debugtalk.go - ./output/hrp run examples/demo-with-go-plugin/testcases/demo_with_funplugin.json + ./output/hrp build -o examples/demo-with-go-plugin/debugtalk.bin examples/demo-with-go-plugin/plugin/debugtalk.go + ./output/hrp run examples/demo-with-go-plugin/testcases/demo.json - name: Run load test demo in examples run: | - go build -o examples/demo-with-go-plugin/debugtalk.bin examples/demo-with-go-plugin/plugin/debugtalk.go - ./output/hrp boom examples/demo-with-go-plugin/testcases/demo_with_funplugin.json --spawn-count 10 --spawn-rate 10 --loop-count 10 + ./output/hrp build -o examples/demo-with-go-plugin/debugtalk.bin examples/demo-with-go-plugin/plugin/debugtalk.go + ./output/hrp boom examples/demo-with-go-plugin/testcases/demo.json --spawn-count 10 --spawn-rate 10 --loop-count 10 scaffold-without-custom-plugin: strategy: @@ -87,9 +91,9 @@ jobs: - name: Run start project run: ./output/hrp startproject demo --ignore-plugin - name: Run generated demo tests - run: ./output/hrp run demo/testcases/demo_without_funplugin.json + run: ./output/hrp run demo/testcases/requests.json - name: Run API test demo in examples - run: ./output/hrp run examples/demo-without-plugin/testcases/demo_without_funplugin.json + run: ./output/hrp run examples/demo-without-plugin/testcases/requests.json - name: Run load test demo in examples run: | - ./output/hrp boom examples/demo-without-plugin/testcases/demo_without_funplugin.json --spawn-count 10 --spawn-rate 10 --loop-count 10 + ./output/hrp boom examples/demo-without-plugin/testcases/requests.json --spawn-count 10 --spawn-rate 10 --loop-count 10 diff --git a/.github/workflows/smoketest.yml b/.github/workflows/smoketest.yml index e08ea6e5..f914858a 100644 --- a/.github/workflows/smoketest.yml +++ b/.github/workflows/smoketest.yml @@ -4,6 +4,8 @@ on: push: branches: - master + - v2 + - v3 pull_request: env: diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index c4235d0f..77e221fc 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -4,6 +4,8 @@ on: push: branches: - master + - v2 + - v3 pull_request: env: diff --git a/Makefile b/Makefile index e999b732..b094ea9c 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,11 @@ build: ## build hrp cli tool @echo "[info] build hrp cli tool" @. scripts/build.sh +.PHONY: install-hooks +install-hooks: ## install git hooks + @find scripts -name "install-*-hook" | awk -F'-' '{s=$$2;for(i=3;i HttpRunner [用户调研问卷][survey] 持续收集中,我们将基于用户反馈动态调整产品特性和需求优先级。 -![flow chart](docs/assets/hrp-flow.jpg) +![flow chart](https://httprunner.com/image/hrp-flow.jpg) [CHANGELOG] | [中文] @@ -104,13 +104,18 @@ Flags: Use "hrp [command] --help" for more information about a command. ``` +## User Cases + + + + ## Subscribe 关注 HttpRunner 的微信公众号,第一时间获得最新资讯。 -HttpRunner +HttpRunner -如果你期望加入 HttpRunner 核心用户群,请填写[用户调研问卷][survey]并留下你的联系方式,作者将拉你进群。 +如果你期望加入 HttpRunner 用户群,请看这里:[HttpRunner v4 用户交流群,它来啦!](https://httprunner.com/blog/join-chat-group) [HttpRunner]: https://github.com/httprunner/httprunner [boomer]: https://github.com/myzhan/boomer diff --git a/README.md b/README.md index 5d7c2632..1097dc07 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ > HttpRunner [用户调研问卷][survey] 持续收集中,我们将基于用户反馈动态调整产品特性和需求优先级。 -![flow chart](docs/assets/hrp-flow.jpg) +![flow chart](https://httprunner.com/image/hrp-flow.jpg) [版本发布日志] | [English] @@ -27,7 +27,7 @@ ## 核心特性 -- 网络协议:完整支持 HTTP(S)/1.1 和 HTTP/2,可扩展支持 WebSocket/TCP/RPC 等更多协议 +- 网络协议:完整支持 HTTP(S)/HTTP2/WebSocket,可扩展支持 TCP/UDP/RPC 等更多协议 - 多格式可选:测试用例支持 YAML/JSON/go test/pytest 格式,并且支持格式互相转换 - 双执行引擎:同时支持 golang/python 两个执行引擎,兼具 go 的高性能和 [pytest] 的丰富生态 - 录制 & 生成:可使用 [HAR]/Postman/Swagger/curl 等生成测试用例;基于链式调用的方法提示也可快速编写测试用例 @@ -97,11 +97,16 @@ Flags: Use "hrp [command] --help" for more information about a command. ``` +## 用户案例 + + + + ## 赞助商 ### 金牌赞助商 -[霍格沃兹测试开发学社](https://ceshiren.com/) +[霍格沃兹测试开发学社](https://ceshiren.com/) > [霍格沃兹测试开发学社](http://qrcode.testing-studio.com/f?from=httprunner&url=https://ceshiren.com)是业界领先的测试开发技术高端教育品牌,隶属于[测吧(北京)科技有限公司](http://qrcode.testing-studio.com/f?from=httprunner&url=https://www.testing-studio.com) 。学院课程由一线大厂测试经理与资深测试开发专家参与研发,实战驱动。课程涵盖 web/app 自动化测试、接口测试、性能测试、安全测试、持续集成/持续交付/DevOps,测试左移&右移、精准测试、测试平台开发、测试管理等内容,帮助测试工程师实现测试开发技术转型。通过优秀的学社制度(奖学金、内推返学费、行业竞赛等多种方式)来实现学员、学社及用人企业的三方共赢。 @@ -109,7 +114,7 @@ Use "hrp [command] --help" for more information about a command. ### 开源服务赞助商 -[Sentry](https://sentry.io/_/open-source/) +[Sentry](https://sentry.io/_/open-source/) HttpRunner is in Sentry Sponsored plan. @@ -117,9 +122,9 @@ HttpRunner is in Sentry Sponsored plan. 关注 HttpRunner 的微信公众号,第一时间获得最新资讯。 -HttpRunner +HttpRunner -如果你期望加入 HttpRunner 核心用户群,请填写[用户调研问卷][survey]并留下你的联系方式,作者将拉你进群。 +如果你期望加入 HttpRunner 用户群,请看这里:[HttpRunner v4 用户交流群,它来啦!](https://httprunner.com/blog/join-chat-group) [HttpRunner]: https://github.com/httprunner/httprunner [boomer]: https://github.com/myzhan/boomer diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b6656601..4469315a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,9 +1,59 @@ # Release History -## v4.0.0-beta2 (2022-05-03) +## v4.1.2 (2022-06-05) + +- fix #1331: use `str_eq` to assert string and digit equality +- fix #1336: extract package in Windows +- fix: install package on MinGW64 +- change: remove `hrp har2case`, replace with `hrp convert` + +## v4.1.1 (2022-05-31) + +- fix: failed to build debugtalk.go without go.mod +- fix: avoid to escape from html special characters like '&' in converted JSON testcase +- fix: display the full step name when referencing testcase in html report +- fix: failed to regenerate debugtalk_gen.go and .debugtalk_gen.py correctly + +## v4.1.0 (2022-05-29) + +- feat: add `wiki` sub-command to open httprunner website +- feat: add `build` sub-command for function plugin **go version** +- feat #1268: convert postman collection to HttpRunner testcase +- feat #1291: run testcases in v2/v3 JSON/YAML format with hrp run/boom command +- feat #1280: support creating empty scaffold project +- fix #1308: load `.env` file as environment variables +- fix #1309: locate plugin file upward recursively until system root dir +- fix #1315: failed to generate a report in failfast mode +- refactor: move base_url to config `environs` +- refactor: implement testcase conversions with `hrp convert` + +## v4.1.0-beta (2022-05-21) + +- feat: add pre-commit-hook to format go/python code + +**go version** + +- feat: add boomer mode(standalone/master/worker) +- feat: support load testing with specified `--profile` configuration file +- fix: step request elapsed timing should contain ContentTransfer part +- fix #1288: unable to go get httprunner v4 +- fix: panic when config didn't exist in testcase file +- fix: disable keep alive and improve RPS accuracy +- fix: improve RPS accuracy + +**python version** + +- feat: support new step type with SQL operation +- feat: support new step type with thrift protocol + +## v4.0.0 (2022-05-05) + +**go version** + +- feat: stat HTTP request latencies (DNSLookup, TCP Connection and so on) - feat: add builtin function `environ`/`ENV` - fix: demo function compatibility - fix #1240: losing host port in har2case diff --git a/docs/assets/hrp-flow.jpg b/docs/assets/hrp-flow.jpg index 01a0d050..aa480ede 100644 Binary files a/docs/assets/hrp-flow.jpg and b/docs/assets/hrp-flow.jpg differ diff --git a/docs/cmd/hrp.md b/docs/cmd/hrp.md index ef528f9d..fdc9a0ff 100644 --- a/docs/cmd/hrp.md +++ b/docs/cmd/hrp.md @@ -30,10 +30,12 @@ Copyright 2017 debugtalk ### SEE ALSO * [hrp boom](hrp_boom.md) - run load test with boomer -* [hrp convert](hrp_convert.md) - convert JSON/YAML testcases to pytest/gotest scripts +* [hrp build](hrp_build.md) - build plugin for testing +* [hrp convert](hrp_convert.md) - convert to JSON/YAML/gotest/pytest testcases * [hrp har2case](hrp_har2case.md) - convert HAR to json/yaml testcase files * [hrp pytest](hrp_pytest.md) - run API test with pytest * [hrp run](hrp_run.md) - run API test with go engine * [hrp startproject](hrp_startproject.md) - create a scaffold project +* [hrp wiki](hrp_wiki.md) - visit https://httprunner.com -###### Auto generated by spf13/cobra on 3-May-2022 +###### Auto generated by spf13/cobra on 29-May-2022 diff --git a/docs/cmd/hrp_boom.md b/docs/cmd/hrp_boom.md index fe95ddab..2eb2185a 100644 --- a/docs/cmd/hrp_boom.md +++ b/docs/cmd/hrp_boom.md @@ -31,6 +31,7 @@ hrp boom [flags] --max-rps int Max RPS that boomer can generate, disabled by default. --mem-profile string Enable memory profiling. --mem-profile-duration duration Memory profile duration. (default 30s) + --profile string profile for load testing --prometheus-gateway string Prometheus Pushgateway url. --request-increase-rate string Request increase rate, disabled by default. (default "-1") --spawn-count int The number of users to spawn for load testing (default 1) @@ -41,4 +42,4 @@ hrp boom [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 3-May-2022 +###### Auto generated by spf13/cobra on 29-May-2022 diff --git a/docs/cmd/hrp_build.md b/docs/cmd/hrp_build.md new file mode 100644 index 00000000..25a5f01d --- /dev/null +++ b/docs/cmd/hrp_build.md @@ -0,0 +1,31 @@ +## hrp build + +build plugin for testing + +### Synopsis + +build python/go plugin for testing + +``` +hrp build $path ... [flags] +``` + +### Examples + +``` + $ hrp build plugin/debugtalk.go + $ hrp build plugin/debugtalk.py +``` + +### Options + +``` + -h, --help help for build + -o, --output string funplugin product output path, default: cwd +``` + +### SEE ALSO + +* [hrp](hrp.md) - Next-Generation API Testing Solution. + +###### Auto generated by spf13/cobra on 29-May-2022 diff --git a/docs/cmd/hrp_convert.md b/docs/cmd/hrp_convert.md index 880bce0b..d063a34d 100644 --- a/docs/cmd/hrp_convert.md +++ b/docs/cmd/hrp_convert.md @@ -1,6 +1,6 @@ ## hrp convert -convert JSON/YAML testcases to pytest/gotest scripts +convert to JSON/YAML/gotest/pytest testcases ``` hrp convert $path... [flags] @@ -9,13 +9,17 @@ hrp convert $path... [flags] ### Options ``` - --gotest convert to gotest scripts (TODO) - -h, --help help for convert - --pytest convert to pytest scripts (default true) + -h, --help help for convert + -d, --output-dir string specify output directory, default to the same dir with har file + -p, --profile string specify profile path to override headers (except for auto-generated headers) and cookies + --to-gotest convert to gotest scripts (TODO) + --to-json convert to JSON scripts (default) + --to-pytest convert to pytest scripts + --to-yaml convert to YAML scripts ``` ### SEE ALSO * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 3-May-2022 +###### Auto generated by spf13/cobra on 29-May-2022 diff --git a/docs/cmd/hrp_har2case.md b/docs/cmd/hrp_har2case.md index d7b6e925..0919562e 100644 --- a/docs/cmd/hrp_har2case.md +++ b/docs/cmd/hrp_har2case.md @@ -16,7 +16,7 @@ hrp har2case $har_path... [flags] -h, --help help for har2case -d, --output-dir string specify output directory, default to the same dir with har file -p, --profile string specify profile path to override headers and cookies - -j, --to-json convert to JSON format (default true) + -j, --to-json convert to JSON format (default) -y, --to-yaml convert to YAML format ``` @@ -24,4 +24,4 @@ hrp har2case $har_path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 3-May-2022 +###### Auto generated by spf13/cobra on 29-May-2022 diff --git a/docs/cmd/hrp_pytest.md b/docs/cmd/hrp_pytest.md index 5c59d217..2fefe6a9 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 3-May-2022 +###### Auto generated by spf13/cobra on 29-May-2022 diff --git a/docs/cmd/hrp_run.md b/docs/cmd/hrp_run.md index 6d2f8486..36cffa03 100644 --- a/docs/cmd/hrp_run.md +++ b/docs/cmd/hrp_run.md @@ -24,6 +24,7 @@ hrp run $path... [flags] -c, --continue-on-failure continue running next step when failure occurs -g, --gen-html-report generate html report -h, --help help for run + --http-stat turn on HTTP latency stat (DNSLookup, TCP Connection, etc.) --log-plugin turn on plugin logging --log-requests-off turn off request & response details logging -p, --proxy-url string set proxy url @@ -34,4 +35,4 @@ hrp run $path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 3-May-2022 +###### Auto generated by spf13/cobra on 29-May-2022 diff --git a/docs/cmd/hrp_startproject.md b/docs/cmd/hrp_startproject.md index a8706619..cd73617f 100644 --- a/docs/cmd/hrp_startproject.md +++ b/docs/cmd/hrp_startproject.md @@ -9,6 +9,7 @@ hrp startproject $project_name [flags] ### Options ``` + --empty generate empty project -f, --force force to overwrite existing project --go generate hashicorp go plugin -h, --help help for startproject @@ -20,4 +21,4 @@ hrp startproject $project_name [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 3-May-2022 +###### Auto generated by spf13/cobra on 29-May-2022 diff --git a/docs/cmd/hrp_wiki.md b/docs/cmd/hrp_wiki.md new file mode 100644 index 00000000..5ae6bde5 --- /dev/null +++ b/docs/cmd/hrp_wiki.md @@ -0,0 +1,19 @@ +## hrp wiki + +visit https://httprunner.com + +``` +hrp wiki [flags] +``` + +### Options + +``` + -h, --help help for wiki +``` + +### SEE ALSO + +* [hrp](hrp.md) - Next-Generation API Testing Solution. + +###### Auto generated by spf13/cobra on 29-May-2022 diff --git a/examples/data/har/demo.json b/examples/data/har/demo.json deleted file mode 100644 index 292ad513..00000000 --- a/examples/data/har/demo.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "config": { - "name": "testcase description" - }, - "teststeps": [ - { - "name": "", - "request": { - "method": "GET", - "url": "https://postman-echo.com/get", - "params": { - "foo1": "HDnY8", - "foo2": "34.5" - }, - "headers": { - "Accept-Encoding": "gzip", - "Host": "postman-echo.com", - "User-Agent": "HttpRunnerPlus" - } - }, - "validate": [ - { - "check": "status_code", - "assert": "equals", - "expect": 200, - "msg": "assert response status code" - }, - { - "check": "headers.\"Content-Type\"", - "assert": "equals", - "expect": "application/json; charset=utf-8", - "msg": "assert response header Content-Type" - }, - { - "check": "body.url", - "assert": "equals", - "expect": "https://postman-echo.com/get?foo1=HDnY8\u0026foo2=34.5", - "msg": "assert response body url" - } - ] - }, - { - "name": "", - "request": { - "method": "POST", - "url": "https://postman-echo.com/post", - "headers": { - "Accept-Encoding": "gzip", - "Content-Length": "28", - "Content-Type": "application/json; charset=UTF-8", - "Host": "postman-echo.com", - "User-Agent": "Go-http-client/1.1" - }, - "cookies": { - "sails.sid": "s%3Az_LpglkKxTvJ_eHVUH6V67drKp0AGWW-.PidabaXOnatLRP47hVyqqepl6BdrpEQzRlJQXtbIiwk" - }, - "body": { - "foo1": "HDnY8", - "foo2": 12.3 - } - }, - "validate": [ - { - "check": "status_code", - "assert": "equals", - "expect": 200, - "msg": "assert response status code" - }, - { - "check": "headers.\"Content-Type\"", - "assert": "equals", - "expect": "application/json; charset=utf-8", - "msg": "assert response header Content-Type" - }, - { - "check": "body.url", - "assert": "equals", - "expect": "https://postman-echo.com/post", - "msg": "assert response body url" - } - ] - }, - { - "name": "", - "request": { - "method": "POST", - "url": "https://postman-echo.com/post", - "headers": { - "Accept-Encoding": "gzip", - "Content-Length": "20", - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "Host": "postman-echo.com", - "User-Agent": "Go-http-client/1.1" - }, - "cookies": { - "sails.sid": "s%3AS5e7w0zQ0xAsCwh9L8T6R7QLYCO7_gtD.r8%2B2w9IWqEIfuVkrZjnxzm2xADIk34zKAWXRPapr%2FAw" - }, - "body": "foo1=HDnY8\u0026foo2=12.3" - }, - "validate": [ - { - "check": "status_code", - "assert": "equals", - "expect": 200, - "msg": "assert response status code" - }, - { - "check": "headers.\"Content-Type\"", - "assert": "equals", - "expect": "application/json; charset=utf-8", - "msg": "assert response header Content-Type" - }, - { - "check": "body.data", - "assert": "equals", - "expect": "", - "msg": "assert response body data" - }, - { - "check": "body.url", - "assert": "equals", - "expect": "https://postman-echo.com/post", - "msg": "assert response body url" - } - ] - } - ] -} \ No newline at end of file diff --git a/examples/data/har/profile.yml b/examples/data/har/profile_override.yml similarity index 86% rename from examples/data/har/profile.yml rename to examples/data/har/profile_override.yml index 69963ba2..35236a52 100644 --- a/examples/data/har/profile.yml +++ b/examples/data/har/profile_override.yml @@ -1,3 +1,4 @@ +override: true headers: Content-Type: "application/x-www-form-urlencoded" cookies: diff --git a/examples/data/postman/postman_collection.json b/examples/data/postman/postman_collection.json new file mode 100644 index 00000000..3b7a9e30 --- /dev/null +++ b/examples/data/postman/postman_collection.json @@ -0,0 +1,488 @@ +{ + "info": { + "_postman_id": "0417a445-b206-4ea2-b1d2-5441afd6c6b9", + "name": "postman collection demo", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "folder1", + "item": [ + { + "name": "folder2", + "item": [ + { + "name": "Get with params", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/:path?k1=v1&k2=v2", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "query": [ + { + "key": "k1", + "value": "v1" + }, + { + "key": "k2", + "value": "v2" + }, + { + "key": "k3", + "value": "v3", + "disabled": true + } + ], + "variable": [ + { + "key": "path", + "value": "get" + } + ] + } + }, + "response": [ + { + "name": "Get with params case1", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/:path?k1=v1&k2=v2", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "query": [ + { + "key": "k1", + "value": "v1" + }, + { + "key": "k2", + "value": "v2" + }, + { + "key": "k3", + "value": "v3", + "disabled": true + } + ], + "variable": [ + { + "key": "path", + "value": "get" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Date", + "value": "Mon, 16 May 2022 12:12:28 GMT" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "508" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "ETag", + "value": "W/\"1fc-x4EIPFQzoLX0HenCFPx6HNfG0lc\"" + }, + { + "key": "Vary", + "value": "Accept-Encoding" + }, + { + "key": "set-cookie", + "value": "sails.sid=s%3AX2aa_Z7gbcUqIWAjlBkytBRmQ4WCvc3D.pX9Qxh8aO9Ict0BL4CrRhdDJmz81UVmwFsV5Nx30Ils; Path=/; HttpOnly" + } + ], + "cookie": [], + "body": "{\n \"args\": {\n \"k1\": \"v1\",\n \"k2\": \"v2\"\n },\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"PostmanRuntime/7.29.0\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\",\n \"cookie\": \"Cookie_1=c1; Cookie_2=c2; sails.sid=s%3AGX6aS9b_phvUSUk66w7ZBgWuOPI7IIKT.ayEGTaW4U35eAWyPz%2Fh6Q74DonNcbqw3H5Q5Zv%2BfKMY\"\n },\n \"url\": \"https://postman-echo.com/get?k1=v1&k2=v2\"\n}" + }, + { + "name": "Get with params case2", + "originalRequest": { + "method": "GET", + "header": [], + "url": { + "raw": "https://postman-echo.com/:path?k1=v1&k3=v3", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "query": [ + { + "key": "k1", + "value": "v1" + }, + { + "key": "k2", + "value": "v2", + "disabled": true + }, + { + "key": "k3", + "value": "v3" + } + ], + "variable": [ + { + "key": "path", + "value": "get" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Date", + "value": "Mon, 16 May 2022 12:14:04 GMT" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "504" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "ETag", + "value": "W/\"1f8-tMaKs4xmwr+3su3I8mcgR0p+ucw\"" + }, + { + "key": "Vary", + "value": "Accept-Encoding" + }, + { + "key": "set-cookie", + "value": "sails.sid=s%3AMNuX_i0KgaP_KuuMpYB8RtCNipCGJWVw.4ETfPHxE81Omqb6Yli%2FezUU8CXyYBcN3%2Bxkx5htwh8Y; Path=/; HttpOnly" + } + ], + "cookie": [], + "body": "{\n \"args\": {\n \"k1\": \"v1\",\n \"k3\": \"v3\"\n },\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"PostmanRuntime/7.29.0\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\",\n \"cookie\": \"Cookie_1=c1; Cookie_2=c2; sails.sid=s%3AX2aa_Z7gbcUqIWAjlBkytBRmQ4WCvc3D.pX9Qxh8aO9Ict0BL4CrRhdDJmz81UVmwFsV5Nx30Ils\"\n },\n \"url\": \"https://postman-echo.com/get?k1=v1&k3=v3\"\n}" + } + ] + } + ] + } + ] + }, + { + "name": "folder3", + "item": [ + { + "name": "Post form-data", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "k1", + "value": "v1", + "type": "text" + }, + { + "key": "k2", + "value": "v2", + "type": "text" + }, + { + "key": "k3", + "value": "v3", + "type": "text", + "disabled": true + } + ] + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + }, + { + "name": "Post x-www-form-urlencoded", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "k1", + "value": "v1", + "type": "text" + }, + { + "key": "k2", + "value": "v2", + "type": "text" + }, + { + "key": "k3", + "value": "v3", + "type": "text", + "disabled": true + } + ] + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + }, + { + "name": "Post raw json", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"k1\": \"v1\",\n \"k2\": \"v2\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + }, + { + "name": "Post raw text", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "have a nice day", + "options": { + "raw": { + "language": "text" + } + } + }, + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "post" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get request headers", + "request": { + "method": "GET", + "header": [ + { + "key": "User-Agent", + "value": "HttpRunner", + "type": "text" + }, + { + "key": "User-Name", + "value": "bbx", + "type": "text", + "disabled": true + }, + { + "key": "Connection", + "value": "close", + "type": "text" + } + ], + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "headers" + } + ] + } + }, + "response": [ + { + "name": "Get request headers case1", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "User-Agent", + "value": "HttpRunner", + "type": "text" + }, + { + "key": "User-Name", + "value": "bbx", + "type": "text", + "disabled": true + }, + { + "key": "Cookie", + "value": "Cookie_1=c1; Cookie_2=c2; sails.sid=s%3AGX6aS9b_phvUSUk66w7ZBgWuOPI7IIKT.ayEGTaW4U35eAWyPz%2Fh6Q74DonNcbqw3H5Q5Zv%2BfKMY", + "type": "text" + } + ], + "url": { + "raw": "https://postman-echo.com/:path", + "protocol": "https", + "host": [ + "postman-echo", + "com" + ], + "path": [ + ":path" + ], + "variable": [ + { + "key": "path", + "value": "headers" + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Date", + "value": "Mon, 16 May 2022 12:14:25 GMT" + }, + { + "key": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "key": "Content-Length", + "value": "541" + }, + { + "key": "Connection", + "value": "keep-alive" + }, + { + "key": "ETag", + "value": "W/\"21d-ld5UvFTaRM6lihVnvCj6mZm5Of0\"" + }, + { + "key": "Vary", + "value": "Accept-Encoding" + } + ], + "cookie": [], + "body": "{\n \"headers\": {\n \"x-forwarded-proto\": \"https\",\n \"x-forwarded-port\": \"443\",\n \"host\": \"postman-echo.com\",\n \"user-agent\": \"HttpRunner\",\n \"cookie\": \"Cookie_1=c1; Cookie_2=c2; sails.sid=s%3AGX6aS9b_phvUSUk66w7ZBgWuOPI7IIKT.ayEGTaW4U35eAWyPz%2Fh6Q74DonNcbqw3H5Q5Zv%2BfKMY\",\n \"accept\": \"*/*\",\n \"accept-encoding\": \"gzip, deflate, br\"\n }\n}" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/data/postman/profile.yml b/examples/data/postman/profile.yml new file mode 100644 index 00000000..c657b5ef --- /dev/null +++ b/examples/data/postman/profile.yml @@ -0,0 +1,4 @@ +headers: + User-Agent: "this header will be created or updated" +cookies: + Cookie1: "this cookie will be created or updated" diff --git a/examples/data/postman/profile_override.yml b/examples/data/postman/profile_override.yml new file mode 100644 index 00000000..bc620e50 --- /dev/null +++ b/examples/data/postman/profile_override.yml @@ -0,0 +1,5 @@ +override: true +headers: + Header1: "all original headers will be overridden" +cookies: + Cookie1: "all original cookies will be overridden" \ No newline at end of file diff --git a/examples/demo-empty-project/.env b/examples/demo-empty-project/.env new file mode 100644 index 00000000..59ecc742 --- /dev/null +++ b/examples/demo-empty-project/.env @@ -0,0 +1,3 @@ +base_url=https://postman-echo.com +USERNAME=debugtalk +PASSWORD=123456 \ No newline at end of file diff --git a/examples/demo-empty-project/.gitignore b/examples/demo-empty-project/.gitignore new file mode 100644 index 00000000..4c8cb60c --- /dev/null +++ b/examples/demo-empty-project/.gitignore @@ -0,0 +1,14 @@ +reports/ +*.so +.vscode/ +.idea/ +.DS_Store +output/ +__pycache__/ +*.pyc +.python-version +logs/ + +# plugin +debugtalk.bin +debugtalk.so diff --git a/examples/demo-empty-project/har/.keep b/examples/demo-empty-project/har/.keep new file mode 100644 index 00000000..e69de29b diff --git a/examples/demo-empty-project/proj.json b/examples/demo-empty-project/proj.json new file mode 100644 index 00000000..5d124adb --- /dev/null +++ b/examples/demo-empty-project/proj.json @@ -0,0 +1,5 @@ +{ + "project_name": "demo-empty-project", + "create_time": "2022-05-31T15:05:51.196187+08:00", + "hrp_version": "v4.1.1" +} \ No newline at end of file diff --git a/examples/demo-empty-project/testcases/requests.json b/examples/demo-empty-project/testcases/requests.json new file mode 100644 index 00000000..fc76e4aa --- /dev/null +++ b/examples/demo-empty-project/testcases/requests.json @@ -0,0 +1,25 @@ +{ + "config": { + "name": "request methods testcase: empty testcase", + "variables": null, + "verify": false + }, + "teststeps": [ + { + "name": "", + "variables": null, + "request": { + "method": "GET", + "url": "https://" + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/demo-with-go-plugin/.env b/examples/demo-with-go-plugin/.env new file mode 100644 index 00000000..59ecc742 --- /dev/null +++ b/examples/demo-with-go-plugin/.env @@ -0,0 +1,3 @@ +base_url=https://postman-echo.com +USERNAME=debugtalk +PASSWORD=123456 \ No newline at end of file diff --git a/examples/demo-with-go-plugin/.gitignore b/examples/demo-with-go-plugin/.gitignore index 33401380..4c8cb60c 100644 --- a/examples/demo-with-go-plugin/.gitignore +++ b/examples/demo-with-go-plugin/.gitignore @@ -1,4 +1,3 @@ -.env reports/ *.so .vscode/ diff --git a/examples/demo-with-go-plugin/plugin/debugtalk.go b/examples/demo-with-go-plugin/plugin/debugtalk.go index b3b39400..73deb244 100644 --- a/examples/demo-with-go-plugin/plugin/debugtalk.go +++ b/examples/demo-with-go-plugin/plugin/debugtalk.go @@ -2,8 +2,6 @@ package main import ( "fmt" - - "github.com/httprunner/funplugin/fungo" ) func SumTwoInt(a, b int) int { @@ -41,17 +39,6 @@ func TeardownHookExample(args string) string { return fmt.Sprintf("step name: %v, teardown...", args) } -func GetVersion() string { - return fungo.Version -} - -func main() { - fungo.Register("get_version", GetVersion) - fungo.Register("sum_ints", SumInts) - fungo.Register("sum_two_int", SumTwoInt) - fungo.Register("sum_two", SumTwoInt) - fungo.Register("sum", Sum) - fungo.Register("setup_hook_example", SetupHookExample) - fungo.Register("teardown_hook_example", TeardownHookExample) - fungo.Serve() +func GetUserAgent() string { + return "hrp/fungo" } diff --git a/examples/demo-with-go-plugin/plugin/go.mod b/examples/demo-with-go-plugin/plugin/go.mod deleted file mode 100644 index 941628d8..00000000 --- a/examples/demo-with-go-plugin/plugin/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module plugin - -go 1.16 - -require github.com/httprunner/funplugin v0.4.3 // indirect diff --git a/examples/demo-with-go-plugin/plugin/go.sum b/examples/demo-with-go-plugin/plugin/go.sum deleted file mode 100644 index 93a4421a..00000000 --- a/examples/demo-with-go-plugin/plugin/go.sum +++ /dev/null @@ -1,196 +0,0 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -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-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-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -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= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -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/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -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-plugin v1.4.3 h1:DXmvivbWD5qdiBts9TpBC7BYL1Aia5sxbRgQB+v6UZM= -github.com/hashicorp/go-plugin v1.4.3/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= -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/httprunner/funplugin v0.4.3 h1:mxdxQh54NZLQnK/FXZxpZV0rhqZQzckrWKEnBW5w2Vg= -github.com/httprunner/funplugin v0.4.3/go.mod h1:vPyeJIfbpGe0epZZtAV0wCn16gLY9+imSw/zfxq0Lcc= -github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= -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/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77 h1:7GoSOOW2jpsfkntVKaS2rAr1TJqfcxotyaUcuxoZSzg= -github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -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/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= -github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -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/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -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/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -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-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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -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= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/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-20210615035016-665e8c7367d1/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-20211216021012-1d35b9e2eb4e/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-20220310020820-b874c991c1a5/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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/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-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -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/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 h1:ErU+UA6wxadoU8nWrsy5MZUVBs75K17zUCsUCIfrXCE= -google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= -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.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M= -google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= -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= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -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 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -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= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/examples/demo-with-go-plugin/proj.json b/examples/demo-with-go-plugin/proj.json new file mode 100644 index 00000000..be9dfdaf --- /dev/null +++ b/examples/demo-with-go-plugin/proj.json @@ -0,0 +1,5 @@ +{ + "project_name": "demo-with-go-plugin", + "create_time": "2022-05-31T15:05:49.894029+08:00", + "hrp_version": "v4.1.1" +} \ No newline at end of file diff --git a/examples/demo-with-go-plugin/testcases/demo_with_funplugin.json b/examples/demo-with-go-plugin/testcases/demo.json similarity index 100% rename from examples/demo-with-go-plugin/testcases/demo_with_funplugin.json rename to examples/demo-with-go-plugin/testcases/demo.json diff --git a/examples/demo-with-go-plugin/testcases/demo_ref_testcase.yml b/examples/demo-with-go-plugin/testcases/ref_testcase.yml similarity index 80% rename from examples/demo-with-go-plugin/testcases/demo_ref_testcase.yml rename to examples/demo-with-go-plugin/testcases/ref_testcase.yml index 0743488e..c0932124 100644 --- a/examples/demo-with-go-plugin/testcases/demo_ref_testcase.yml +++ b/examples/demo-with-go-plugin/testcases/ref_testcase.yml @@ -13,7 +13,7 @@ teststeps: variables: foo1: testcase_ref_bar1 expect_foo1: testcase_ref_bar1 - testcase: testcases/demo_requests.yml + testcase: testcases/requests.yml export: - foo3 - @@ -24,10 +24,10 @@ teststeps: method: POST url: /post headers: - User-Agent: funplugin/${get_version()} + User-Agent: ${get_user_agent()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo3" + body: "foo1=$foo1&foo2=$foo3" validate: - eq: ["status_code", 200] - eq: ["body.form.foo1", "bar1"] - - eq: ["body.form.foo2", "bar21"] + - eq: ["body.form.foo2", "bar21"] \ No newline at end of file diff --git a/examples/demo-with-go-plugin/testcases/requests.json b/examples/demo-with-go-plugin/testcases/requests.json new file mode 100644 index 00000000..75c464f9 --- /dev/null +++ b/examples/demo-with-go-plugin/testcases/requests.json @@ -0,0 +1,136 @@ +{ + "config": { + "name": "request methods testcase with functions", + "variables": { + "foo1": "config_bar1", + "foo2": "config_bar2", + "expect_foo1": "config_bar1", + "expect_foo2": "config_bar2" + }, + "headers": { + "User-Agent": "${get_user_agent()}" + }, + "base_url": "https://postman-echo.com", + "verify": false, + "export": [ + "foo3" + ] + }, + "teststeps": [ + { + "name": "get with params", + "variables": { + "foo1": "${ENV(USERNAME)}", + "foo2": "bar21", + "sum_v": "${sum_two_int(1, 2)}" + }, + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "$foo1", + "foo2": "$foo2", + "sum_v": "$sum_v" + } + }, + "extract": { + "foo3": "body.args.foo2" + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + }, + { + "check": "body.args.foo1", + "assert": "equal", + "expect": "debugtalk", + "msg": "check body.args.foo1" + }, + { + "check": "body.args.sum_v", + "assert": "equal", + "expect": "3", + "msg": "check body.args.sum_v" + }, + { + "check": "body.args.foo2", + "assert": "equal", + "expect": "bar21", + "msg": "check body.args.foo2" + } + ] + }, + { + "name": "post raw text", + "variables": { + "foo1": "bar12", + "foo3": "bar32" + }, + "request": { + "method": "POST", + "url": "/post", + "headers": { + "Content-Type": "text/plain" + }, + "body": "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + }, + { + "check": "body.data", + "assert": "equal", + "expect": "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32.", + "msg": "check body.data" + } + ] + }, + { + "name": "post form data", + "variables": { + "foo2": "bar23" + }, + "request": { + "method": "POST", + "url": "/post", + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "body": "foo1=$foo1&foo2=$foo2&foo3=$foo3" + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + }, + { + "check": "body.form.foo1", + "assert": "equal", + "expect": "$expect_foo1", + "msg": "check body.form.foo1" + }, + { + "check": "body.form.foo2", + "assert": "equal", + "expect": "bar23", + "msg": "check body.form.foo2" + }, + { + "check": "body.form.foo3", + "assert": "equal", + "expect": "bar21", + "msg": "check body.form.foo3" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/demo-with-go-plugin/testcases/demo_requests.yml b/examples/demo-with-go-plugin/testcases/requests.yml similarity index 75% rename from examples/demo-with-go-plugin/testcases/demo_requests.yml rename to examples/demo-with-go-plugin/testcases/requests.yml index 86d1b9cc..1db4e4d1 100644 --- a/examples/demo-with-go-plugin/testcases/demo_requests.yml +++ b/examples/demo-with-go-plugin/testcases/requests.yml @@ -5,7 +5,8 @@ config: foo2: config_bar2 expect_foo1: config_bar1 expect_foo2: config_bar2 - base_url: "https://postman-echo.com" + headers: + User-Agent: ${get_user_agent()} verify: False export: ["foo3"] @@ -13,23 +14,21 @@ teststeps: - name: get with params variables: - foo1: bar11 + foo1: ${ENV(USERNAME)} foo2: bar21 sum_v: "${sum_two_int(1, 2)}" request: method: GET - url: /get + url: $base_url/get params: foo1: $foo1 foo2: $foo2 sum_v: $sum_v - headers: - User-Agent: funplugin/${get_version()} extract: foo3: "body.args.foo2" validate: - eq: ["status_code", 200] - - eq: ["body.args.foo1", "bar11"] + - eq: ["body.args.foo1", "debugtalk"] - eq: ["body.args.sum_v", "3"] - eq: ["body.args.foo2", "bar21"] - @@ -39,11 +38,10 @@ teststeps: foo3: "bar32" request: method: POST - url: /post + url: $base_url/post headers: - User-Agent: funplugin/${get_version()} Content-Type: "text/plain" - data: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + body: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." validate: - eq: ["status_code", 200] - eq: ["body.data", "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32."] @@ -53,11 +51,10 @@ teststeps: foo2: bar23 request: method: POST - url: /post + url: $base_url/post headers: - User-Agent: funplugin/${get_version()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo2&foo3=$foo3" + body: "foo1=$foo1&foo2=$foo2&foo3=$foo3" validate: - eq: ["status_code", 200] - eq: ["body.form.foo1", "$expect_foo1"] diff --git a/examples/demo-with-py-plugin/.debugtalk_gen.py b/examples/demo-with-py-plugin/.debugtalk_gen.py new file mode 100644 index 00000000..d3b72a66 --- /dev/null +++ b/examples/demo-with-py-plugin/.debugtalk_gen.py @@ -0,0 +1,75 @@ +# NOTE: Generated By hrp v4.1.1, DO NOT EDIT! + +import logging +import time +import funppy + +from typing import List + + +def get_user_agent(): + return "hrp/funppy" + + +def sleep(n_secs): + time.sleep(n_secs) + + +def sum(*args): + result = 0 + for arg in args: + result += arg + return result + + +def sum_ints(*args: List[int]) -> int: + result = 0 + for arg in args: + result += arg + return result + + +def sum_two_int(a: int, b: int) -> int: + return a + b + + +def sum_two_string(a: str, b: str) -> str: + return a + b + + +def sum_strings(*args: List[str]) -> str: + result = "" + for arg in args: + result += arg + return result + + +def concatenate(*args: List[str]) -> str: + result = "" + for arg in args: + result += str(arg) + return result + + +def setup_hook_example(name): + logging.warning("setup_hook_example") + return f"setup_hook_example: {name}" + + +def teardown_hook_example(name): + logging.warning("teardown_hook_example") + return f"teardown_hook_example: {name}" + + +if __name__ == "__main__": + 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/.env b/examples/demo-with-py-plugin/.env new file mode 100644 index 00000000..59ecc742 --- /dev/null +++ b/examples/demo-with-py-plugin/.env @@ -0,0 +1,3 @@ +base_url=https://postman-echo.com +USERNAME=debugtalk +PASSWORD=123456 \ No newline at end of file diff --git a/examples/demo-with-py-plugin/.gitignore b/examples/demo-with-py-plugin/.gitignore index 33401380..4c8cb60c 100644 --- a/examples/demo-with-py-plugin/.gitignore +++ b/examples/demo-with-py-plugin/.gitignore @@ -1,4 +1,3 @@ -.env reports/ *.so .vscode/ diff --git a/examples/demo-with-py-plugin/debugtalk.py b/examples/demo-with-py-plugin/debugtalk.py index 9fd41120..ea48ff48 100644 --- a/examples/demo-with-py-plugin/debugtalk.py +++ b/examples/demo-with-py-plugin/debugtalk.py @@ -2,11 +2,9 @@ import logging import time from typing import List -import funppy - -def get_version(): - return funppy.__version__ +def get_user_agent(): + return "hrp/funppy" def sleep(n_secs): @@ -57,17 +55,3 @@ def setup_hook_example(name): def teardown_hook_example(name): logging.warning("teardown_hook_example") return f"teardown_hook_example: {name}" - - -if __name__ == "__main__": - funppy.register("get_version", get_version) - funppy.register("sum", sum) - funppy.register("sum_ints", sum_ints) - funppy.register("concatenate", concatenate) - funppy.register("sum_two_int", sum_two_int) - funppy.register("sum_two", sum_two_int) - funppy.register("sum_two_string", sum_two_string) - funppy.register("sum_strings", sum_strings) - 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 new file mode 100644 index 00000000..ccf2b5d5 --- /dev/null +++ b/examples/demo-with-py-plugin/proj.json @@ -0,0 +1,5 @@ +{ + "project_name": "demo-with-py-plugin", + "create_time": "2022-05-31T15:05:50.036068+08:00", + "hrp_version": "v4.1.1" +} \ No newline at end of file diff --git a/examples/demo-with-py-plugin/testcases/demo_with_funplugin.json b/examples/demo-with-py-plugin/testcases/demo.json similarity index 100% rename from examples/demo-with-py-plugin/testcases/demo_with_funplugin.json rename to examples/demo-with-py-plugin/testcases/demo.json diff --git a/examples/demo-with-py-plugin/testcases/demo_ref_testcase.yml b/examples/demo-with-py-plugin/testcases/ref_testcase.yml similarity index 80% rename from examples/demo-with-py-plugin/testcases/demo_ref_testcase.yml rename to examples/demo-with-py-plugin/testcases/ref_testcase.yml index 0743488e..c0932124 100644 --- a/examples/demo-with-py-plugin/testcases/demo_ref_testcase.yml +++ b/examples/demo-with-py-plugin/testcases/ref_testcase.yml @@ -13,7 +13,7 @@ teststeps: variables: foo1: testcase_ref_bar1 expect_foo1: testcase_ref_bar1 - testcase: testcases/demo_requests.yml + testcase: testcases/requests.yml export: - foo3 - @@ -24,10 +24,10 @@ teststeps: method: POST url: /post headers: - User-Agent: funplugin/${get_version()} + User-Agent: ${get_user_agent()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo3" + body: "foo1=$foo1&foo2=$foo3" validate: - eq: ["status_code", 200] - eq: ["body.form.foo1", "bar1"] - - eq: ["body.form.foo2", "bar21"] + - eq: ["body.form.foo2", "bar21"] \ No newline at end of file diff --git a/examples/demo-with-py-plugin/testcases/requests.json b/examples/demo-with-py-plugin/testcases/requests.json new file mode 100644 index 00000000..75c464f9 --- /dev/null +++ b/examples/demo-with-py-plugin/testcases/requests.json @@ -0,0 +1,136 @@ +{ + "config": { + "name": "request methods testcase with functions", + "variables": { + "foo1": "config_bar1", + "foo2": "config_bar2", + "expect_foo1": "config_bar1", + "expect_foo2": "config_bar2" + }, + "headers": { + "User-Agent": "${get_user_agent()}" + }, + "base_url": "https://postman-echo.com", + "verify": false, + "export": [ + "foo3" + ] + }, + "teststeps": [ + { + "name": "get with params", + "variables": { + "foo1": "${ENV(USERNAME)}", + "foo2": "bar21", + "sum_v": "${sum_two_int(1, 2)}" + }, + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "$foo1", + "foo2": "$foo2", + "sum_v": "$sum_v" + } + }, + "extract": { + "foo3": "body.args.foo2" + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + }, + { + "check": "body.args.foo1", + "assert": "equal", + "expect": "debugtalk", + "msg": "check body.args.foo1" + }, + { + "check": "body.args.sum_v", + "assert": "equal", + "expect": "3", + "msg": "check body.args.sum_v" + }, + { + "check": "body.args.foo2", + "assert": "equal", + "expect": "bar21", + "msg": "check body.args.foo2" + } + ] + }, + { + "name": "post raw text", + "variables": { + "foo1": "bar12", + "foo3": "bar32" + }, + "request": { + "method": "POST", + "url": "/post", + "headers": { + "Content-Type": "text/plain" + }, + "body": "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + }, + { + "check": "body.data", + "assert": "equal", + "expect": "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32.", + "msg": "check body.data" + } + ] + }, + { + "name": "post form data", + "variables": { + "foo2": "bar23" + }, + "request": { + "method": "POST", + "url": "/post", + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "body": "foo1=$foo1&foo2=$foo2&foo3=$foo3" + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + }, + { + "check": "body.form.foo1", + "assert": "equal", + "expect": "$expect_foo1", + "msg": "check body.form.foo1" + }, + { + "check": "body.form.foo2", + "assert": "equal", + "expect": "bar23", + "msg": "check body.form.foo2" + }, + { + "check": "body.form.foo3", + "assert": "equal", + "expect": "bar21", + "msg": "check body.form.foo3" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/demo-with-py-plugin/testcases/demo_requests.yml b/examples/demo-with-py-plugin/testcases/requests.yml similarity index 75% rename from examples/demo-with-py-plugin/testcases/demo_requests.yml rename to examples/demo-with-py-plugin/testcases/requests.yml index 86d1b9cc..1db4e4d1 100644 --- a/examples/demo-with-py-plugin/testcases/demo_requests.yml +++ b/examples/demo-with-py-plugin/testcases/requests.yml @@ -5,7 +5,8 @@ config: foo2: config_bar2 expect_foo1: config_bar1 expect_foo2: config_bar2 - base_url: "https://postman-echo.com" + headers: + User-Agent: ${get_user_agent()} verify: False export: ["foo3"] @@ -13,23 +14,21 @@ teststeps: - name: get with params variables: - foo1: bar11 + foo1: ${ENV(USERNAME)} foo2: bar21 sum_v: "${sum_two_int(1, 2)}" request: method: GET - url: /get + url: $base_url/get params: foo1: $foo1 foo2: $foo2 sum_v: $sum_v - headers: - User-Agent: funplugin/${get_version()} extract: foo3: "body.args.foo2" validate: - eq: ["status_code", 200] - - eq: ["body.args.foo1", "bar11"] + - eq: ["body.args.foo1", "debugtalk"] - eq: ["body.args.sum_v", "3"] - eq: ["body.args.foo2", "bar21"] - @@ -39,11 +38,10 @@ teststeps: foo3: "bar32" request: method: POST - url: /post + url: $base_url/post headers: - User-Agent: funplugin/${get_version()} Content-Type: "text/plain" - data: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + body: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." validate: - eq: ["status_code", 200] - eq: ["body.data", "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32."] @@ -53,11 +51,10 @@ teststeps: foo2: bar23 request: method: POST - url: /post + url: $base_url/post headers: - User-Agent: funplugin/${get_version()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo2&foo3=$foo3" + body: "foo1=$foo1&foo2=$foo2&foo3=$foo3" validate: - eq: ["status_code", 200] - eq: ["body.form.foo1", "$expect_foo1"] diff --git a/examples/demo-without-plugin/.env b/examples/demo-without-plugin/.env new file mode 100644 index 00000000..59ecc742 --- /dev/null +++ b/examples/demo-without-plugin/.env @@ -0,0 +1,3 @@ +base_url=https://postman-echo.com +USERNAME=debugtalk +PASSWORD=123456 \ No newline at end of file diff --git a/examples/demo-without-plugin/.gitignore b/examples/demo-without-plugin/.gitignore index 33401380..4c8cb60c 100644 --- a/examples/demo-without-plugin/.gitignore +++ b/examples/demo-without-plugin/.gitignore @@ -1,4 +1,3 @@ -.env reports/ *.so .vscode/ diff --git a/examples/demo-without-plugin/proj.json b/examples/demo-without-plugin/proj.json new file mode 100644 index 00000000..7c95fdb6 --- /dev/null +++ b/examples/demo-without-plugin/proj.json @@ -0,0 +1,5 @@ +{ + "project_name": "demo-without-plugin", + "create_time": "2022-05-31T15:05:51.066376+08:00", + "hrp_version": "v4.1.1" +} \ No newline at end of file diff --git a/examples/demo-without-plugin/testcases/demo_without_funplugin.json b/examples/demo-without-plugin/testcases/requests.json similarity index 100% rename from examples/demo-without-plugin/testcases/demo_without_funplugin.json rename to examples/demo-without-plugin/testcases/requests.json diff --git a/examples/hrp/parameters_test.json b/examples/hrp/parameters_test.json index 84c36531..4af949ed 100644 --- a/examples/hrp/parameters_test.json +++ b/examples/hrp/parameters_test.json @@ -9,9 +9,15 @@ "username-password": "${parameterize($file)}" }, "parameters_setting": { + "pick_order": "random", "strategies": { - "user_agent": "sequential", - "username-password": "random" + "user_agent": { + "name": "user-identity", + "pick_order": "sequential" + }, + "username-password": { + "name": "user-info" + } }, "limit": 6 }, diff --git a/examples/hrp/parameters_test.yaml b/examples/hrp/parameters_test.yaml index b5d06c71..23fd8c01 100644 --- a/examples/hrp/parameters_test.yaml +++ b/examples/hrp/parameters_test.yaml @@ -4,9 +4,13 @@ config: user_agent: [ "iOS/10.1", "iOS/10.2" ] username-password: ${parameterize($file)} parameters_setting: + pick_order: "random" strategies: - user_agent: "sequential" - username-password: "random" + user_agent: + name: "user-identity" + pick_order: "sequential" + username-password: + name: "user-info" limit: 6 variables: app_version: v1 diff --git a/go.mod b/go.mod index 510515e6..198dcf26 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,16 @@ -module github.com/httprunner/httprunner +module github.com/httprunner/httprunner/v4 go 1.16 require ( github.com/andybalholm/brotli v1.0.4 github.com/denisbrodbeck/machineid v1.0.1 + github.com/fatih/color v1.13.0 github.com/getsentry/sentry-go v0.13.0 + github.com/go-openapi/spec v0.20.6 github.com/google/uuid v1.3.0 github.com/gorilla/websocket v1.4.1 - github.com/httprunner/funplugin v0.4.4 + github.com/httprunner/funplugin v0.4.8 github.com/jinzhu/copier v0.3.2 github.com/jmespath/go-jmespath v0.4.0 github.com/json-iterator/go v1.1.12 @@ -21,7 +23,7 @@ require ( github.com/spf13/cobra v1.2.1 github.com/stretchr/testify v1.7.0 golang.org/x/net v0.0.0-20220225172249-27dd8689420f - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b + gopkg.in/yaml.v3 v3.0.0 ) // replace github.com/httprunner/funplugin => ../funplugin diff --git a/go.sum b/go.sum index 24c26644..b3891bad 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,7 @@ github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF 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/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= @@ -106,8 +107,9 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.m github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= 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 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 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= @@ -130,6 +132,16 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 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-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +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/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-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= @@ -241,8 +253,8 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p 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/httprunner/funplugin v0.4.4 h1:IVt603Y57WfSbn6DZ0R4iLeGQJ1yj944gmYwEOSBzGo= -github.com/httprunner/funplugin v0.4.4/go.mod h1:vPyeJIfbpGe0epZZtAV0wCn16gLY9+imSw/zfxq0Lcc= +github.com/httprunner/funplugin v0.4.8 h1:G785jrEn6EAEg2nwuPcCQUHBTgwgoaSz5qdQU4X3JpI= +github.com/httprunner/funplugin v0.4.8/go.mod h1:vPyeJIfbpGe0epZZtAV0wCn16gLY9+imSw/zfxq0Lcc= 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= @@ -261,6 +273,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y 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= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +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= @@ -287,22 +301,27 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv 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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 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/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/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= @@ -346,6 +365,8 @@ github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5Vgl 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/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= @@ -835,8 +856,9 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 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/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= @@ -852,8 +874,10 @@ 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-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/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= diff --git a/hrp/boomer.go b/hrp/boomer.go index 377e61f3..caef351c 100644 --- a/hrp/boomer.go +++ b/hrp/boomer.go @@ -5,11 +5,11 @@ import ( "sync" "time" + "github.com/httprunner/funplugin" "github.com/rs/zerolog/log" - "github.com/httprunner/funplugin" - "github.com/httprunner/httprunner/hrp/internal/boomer" - "github.com/httprunner/httprunner/hrp/internal/sdk" + "github.com/httprunner/httprunner/v4/hrp/internal/boomer" + "github.com/httprunner/httprunner/v4/hrp/internal/sdk" ) func NewBoomer(spawnCount int, spawnRate float64) *HRPBoomer { @@ -19,9 +19,6 @@ func NewBoomer(spawnCount int, spawnRate float64) *HRPBoomer { } b.hrpRunner = NewRunner(nil) - // set client transport for high concurrency load testing - b.hrpRunner.SetClientTransport(b.GetSpawnCount(), b.GetDisableKeepAlive(), b.GetDisableCompression()) - return b } @@ -32,6 +29,11 @@ type HRPBoomer struct { pluginsMutex *sync.RWMutex // avoid data race } +func (b *HRPBoomer) SetClientTransport() { + // set client transport for high concurrency load testing + b.hrpRunner.SetClientTransport(b.GetSpawnCount(), b.GetDisableKeepAlive(), b.GetDisableCompression()) +} + // Run starts to run load test for one or multiple testcases. func (b *HRPBoomer) Run(testcases ...ITestCase) { event := sdk.EventTracking{ @@ -97,6 +99,9 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend parametersIterator := caseRunner.parametersIterator parametersIterator.SetUnlimitedMode() + // reset start time only once + once := sync.Once{} + return &boomer.Task{ Name: testcase.Config.Name, Weight: testcase.Config.Weight, @@ -113,6 +118,10 @@ func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rend startTime := time.Now() for _, step := range testcase.TestSteps { + // reset start time only once before step + once.Do(func() { + b.Boomer.ResetStartTime() + }) stepResult, err := step.Run(sessionRunner) if err != nil { // step failed diff --git a/hrp/boomer_test.go b/hrp/boomer_test.go index 4edefa38..547a4618 100644 --- a/hrp/boomer_test.go +++ b/hrp/boomer_test.go @@ -25,10 +25,10 @@ func TestBoomerStandaloneRun(t *testing.T) { NewStep("TestCase3").CallRefCase(&TestCase{Config: NewConfig("TestCase3")}), }, } - testcase2 := &demoTestCaseWithPluginJSONPath + testcase2 := TestCasePath(demoTestCaseWithPluginJSONPath) b := NewBoomer(2, 1) - go b.Run(testcase1, testcase2) + go b.Run(testcase1, &testcase2) time.Sleep(5 * time.Second) b.Quit() } diff --git a/hrp/build.go b/hrp/build.go new file mode 100644 index 00000000..4c5cb940 --- /dev/null +++ b/hrp/build.go @@ -0,0 +1,291 @@ +package hrp + +import ( + "bufio" + _ "embed" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "text/template" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + + "github.com/httprunner/funplugin/shared" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/version" +) + +const ( + funppy = `import funppy` + fungo = `"github.com/httprunner/funplugin/fungo"` + regexPythonFunctionName = `def ([a-zA-Z_]\w*)\(.*\)` + regexGoImports = `import \(([\s\S]*?)\)` + regexGoImport = `import (\"[\s\S]*\")` + regexGoFunctionName = `func ([A-Z][a-zA-Z_]\w*)\(.*\)` + regexGoFunctionContent = `func [\s\S]*?\n}` +) + +//go:embed internal/scaffold/templates/plugin/debugtalkPythonTemplate +var pyTemplate string + +//go:embed internal/scaffold/templates/plugin/debugtalkGoTemplate +var goTemplate string + +type TemplateContent struct { + Version string // hrp version + Fun string // funplugin package + Regexps *Regexps // match import/function + Imports []string // python/go import + FromImports []string // python from...import... + Functions []string // python/go function + FunctionNames []string // function name set by user +} + +type Regexps struct { + Import *regexp.Regexp + Imports *regexp.Regexp + FunctionName *regexp.Regexp + FunctionContent *regexp.Regexp // including function define and body +} + +func (t *TemplateContent) parseGoContent(path string) error { + log.Info().Str("path", path).Msg("start to parse debugtalk.go") + + content, err := os.ReadFile(path) + if err != nil { + log.Error().Err(err).Msg("failed to read file") + return err + } + originalContent := string(content) + + // parse imports + importSlice := t.Regexps.Imports.FindAllStringSubmatch(originalContent, -1) + if len(importSlice) != 0 { + imports := strings.Replace(importSlice[0][1], "\t", "", -1) + for _, elem := range strings.Split(imports, "\n") { + t.Imports = append(t.Imports, strings.TrimSpace(elem)) + } + } + // parse import + importSlice = t.Regexps.Import.FindAllStringSubmatch(originalContent, -1) + if len(importSlice) != 0 { + for _, elem := range importSlice { + t.Imports = append(t.Imports, strings.TrimSpace(elem[1])) + } + } + // import fungo package + if !builtin.Contains(t.Imports, fungo) { + t.Imports = append(t.Imports, t.Fun) + } + + // parse function name + functionNameSlice := t.Regexps.FunctionName.FindAllStringSubmatch(originalContent, -1) + for _, elem := range functionNameSlice { + name := strings.Trim(elem[1], " ") + if name == "main" { + continue + } + t.FunctionNames = append(t.FunctionNames, name) + } + + // parse function content + functionContentSlice := t.Regexps.FunctionContent.FindAllStringSubmatch(originalContent, -1) + for _, f := range functionContentSlice { + if strings.Contains(f[0], "func main") { + continue + } + t.Functions = append(t.Functions, strings.Trim(f[0], "\n")) + } + return nil +} + +func (t *TemplateContent) parsePyContent(path string) error { + file, err := os.Open(path) + if err != nil { + log.Error().Err(err).Str("path", path).Msg("failed to open file") + return err + } + defer file.Close() + + r := bufio.NewReader(file) + + // record content excluding import and main + content := "" + + // parse python content line by line + for { + l, _, err := r.ReadLine() + if err == io.EOF { + break + } + line := string(l) + + if strings.HasPrefix(line, "import") { + t.Imports = append(t.Imports, strings.Trim(line, " ")) + } else if strings.HasPrefix(line, "from") { + t.FromImports = append(t.FromImports, strings.Trim(line, " ")) + } else { + // no parse content at under of `if __name__ == "__main__"` + if strings.HasPrefix(line, "if __name__") { + break + } + if strings.HasPrefix(line, "def") { + functionNameSlice := t.Regexps.FunctionName.FindAllStringSubmatch(line, -1) + if len(functionNameSlice) == 0 { + continue + } + t.FunctionNames = append(t.FunctionNames, functionNameSlice[0][1]) + } + content += line + "\n" + } + } + // function content + t.Functions = append(t.Functions, strings.Trim(content, "\n")) + + // import funppy + if !builtin.Contains(t.Imports, t.Fun) { + t.Imports = append(t.Imports, t.Fun) + } + return nil +} + +func (t *TemplateContent) genDebugTalk(path string, templ string) error { + file, err := os.Create(path) + if err != nil { + log.Error().Err(err).Msg("open file failed") + return err + } + defer file.Close() + writer := bufio.NewWriter(file) + tmpl := template.Must(template.New("debugtalk").Parse(templ)) + err = tmpl.Execute(writer, t) + if err != nil { + log.Error().Err(err).Msg("execute applies a parsed template to the specified data object failed") + return err + } + err = writer.Flush() + if err == nil { + log.Info().Str("path", path).Msg("generate debugtalk success") + } else { + log.Error().Str("path", path).Msg("generate debugtalk failed") + } + return err +} + +// buildGo builds debugtalk.go to debugtalk.bin +func buildGo(path string, output string) error { + templateContent := &TemplateContent{ + Version: version.VERSION, + Fun: fungo, + Regexps: &Regexps{ + Import: regexp.MustCompile(regexGoImport), + Imports: regexp.MustCompile(regexGoImports), + FunctionName: regexp.MustCompile(regexGoFunctionName), + FunctionContent: regexp.MustCompile(regexGoFunctionContent), + }, + } + + pluginDir := filepath.Dir(path) + + // check go sdk in tempDir + if err := builtin.ExecCommandInDir(exec.Command("go", "version"), pluginDir); err != nil { + return errors.Wrap(err, "go sdk not installed") + } + + // parse debugtalk.go in pluginDir + err := templateContent.parseGoContent(path) + if err != nil { + return err + } + + // generate debugtalk.go in pluginDir + err = templateContent.genDebugTalk(filepath.Join(pluginDir, PluginGoSourceGenFile), goTemplate) + if err != nil { + return err + } + + if !builtin.IsFilePathExists(filepath.Join(pluginDir, "go.mod")) { + // create go mod + if err := builtin.ExecCommandInDir(exec.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 { + return err + } + } + + // add missing and remove unused modules + if err := builtin.ExecCommandInDir(exec.Command("go", "mod", "tidy"), pluginDir); err != nil { + return err + } + + if output == "" { + dir, _ := os.Getwd() + output = filepath.Join(dir, PluginHashicorpGoBuiltFile) + } else if builtin.IsFolderPathExists(output) { + output = filepath.Join(output, PluginHashicorpGoBuiltFile) + } + outputPath, err := filepath.Abs(output) + if err != nil { + return err + } + + // build plugin debugtalk.bin + cmd := exec.Command("go", "build", "-o", outputPath, PluginGoSourceGenFile, filepath.Base(path)) + if err := builtin.ExecCommandInDir(cmd, pluginDir); err != nil { + return err + } + log.Info().Str("output", outputPath).Str("plugin", path).Msg("build plugin successfully") + return nil +} + +// buildPy completes funppy information in debugtalk.py +func buildPy(path string, output string) error { + templateContent := &TemplateContent{ + Version: version.VERSION, + Fun: funppy, + Regexps: &Regexps{ + FunctionName: regexp.MustCompile(regexPythonFunctionName), + }, + } + err := templateContent.parsePyContent(path) + if err != nil { + return err + } + + // generate .debugtalk_gen.py + if output == "" { + dir, _ := os.Getwd() + output = filepath.Join(dir, PluginPySourceGenFile) + } else if builtin.IsFolderPathExists(output) { + output = filepath.Join(output, PluginPySourceGenFile) + } + err = templateContent.genDebugTalk(output, pyTemplate) + return err +} + +func BuildPlugin(path string, output string) (err error) { + ext := filepath.Ext(path) + switch ext { + case ".py": + err = buildPy(path, output) + case ".go": + err = buildGo(path, output) + default: + return errors.New("type error, expected .py or .go") + } + if err != nil { + log.Error().Err(err).Str("arg", path).Msg("build plugin failed") + os.Exit(1) + } + return nil +} diff --git a/hrp/build_test.go b/hrp/build_test.go new file mode 100644 index 00000000..c527b4ee --- /dev/null +++ b/hrp/build_test.go @@ -0,0 +1,44 @@ +package hrp + +import ( + "path/filepath" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" +) + +func TestRun(t *testing.T) { + err := BuildPlugin(tmpl("plugin/debugtalk.go"), "./debugtalk.bin") + if !assert.Nil(t, err) { + t.Fatal() + } + + genDebugTalkPyPath := filepath.Join(tmpl("plugin/"), PluginPySourceGenFile) + err = BuildPlugin(tmpl("plugin/debugtalk.py"), genDebugTalkPyPath) + if !assert.Nil(t, err) { + t.Fatal() + } + + contentBytes, err := builtin.ReadFile(genDebugTalkPyPath) + if !assert.Nil(t, err) { + t.Fatal() + } + + content := string(contentBytes) + if !assert.Contains(t, content, "import funppy") { + t.Fatal() + } + + if !assert.Contains(t, content, "funppy.register") { + t.Fatal() + } + + reg, _ := regexp.Compile(`funppy\.register`) + matchedSlice := reg.FindAllStringSubmatch(content, -1) + if !assert.Len(t, matchedSlice, 10) { + t.Fatal() + } +} diff --git a/hrp/cmd/boom.go b/hrp/cmd/boom.go index 2751ab52..5ae038e1 100644 --- a/hrp/cmd/boom.go +++ b/hrp/cmd/boom.go @@ -1,12 +1,15 @@ package cmd import ( + "os" "time" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/hrp" - "github.com/httprunner/httprunner/hrp/internal/boomer" + "github.com/httprunner/httprunner/v4/hrp" + "github.com/httprunner/httprunner/v4/hrp/internal/boomer" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" ) // boomCmd represents the boom command @@ -28,56 +31,70 @@ var boomCmd = &cobra.Command{ path := hrp.TestCasePath(arg) paths = append(paths, &path) } - hrpBoomer := hrp.NewBoomer(spawnCount, spawnRate) - hrpBoomer.SetRateLimiter(maxRPS, requestIncreaseRate) - if loopCount > 0 { - hrpBoomer.SetLoopCount(loopCount) + // 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) + } } - if !disableConsoleOutput { + + hrpBoomer := hrp.NewBoomer(boomArgs.SpawnCount, boomArgs.SpawnRate) + hrpBoomer.SetRateLimiter(boomArgs.MaxRPS, boomArgs.RequestIncreaseRate) + if boomArgs.LoopCount > 0 { + hrpBoomer.SetLoopCount(boomArgs.LoopCount) + } + if !boomArgs.DisableConsoleOutput { hrpBoomer.AddOutput(boomer.NewConsoleOutput()) } - if prometheusPushgatewayURL != "" { - hrpBoomer.AddOutput(boomer.NewPrometheusPusherOutput(prometheusPushgatewayURL, "hrp")) + if boomArgs.PrometheusPushgatewayURL != "" { + hrpBoomer.AddOutput(boomer.NewPrometheusPusherOutput(boomArgs.PrometheusPushgatewayURL, "hrp", hrpBoomer.GetMode())) } - hrpBoomer.SetDisableKeepAlive(disableKeepalive) - hrpBoomer.SetDisableCompression(disableCompression) - hrpBoomer.EnableCPUProfile(cpuProfile, cpuProfileDuration) - hrpBoomer.EnableMemoryProfile(memoryProfile, memoryProfileDuration) + hrpBoomer.SetDisableKeepAlive(boomArgs.DisableKeepalive) + hrpBoomer.SetDisableCompression(boomArgs.DisableCompression) + hrpBoomer.SetClientTransport() + hrpBoomer.EnableCPUProfile(boomArgs.CPUProfile, boomArgs.CPUProfileDuration) + hrpBoomer.EnableMemoryProfile(boomArgs.MemoryProfile, boomArgs.MemoryProfileDuration) hrpBoomer.EnableGracefulQuit() hrpBoomer.Run(paths...) }, } -var ( - spawnCount int - spawnRate float64 - maxRPS int64 - loopCount int64 - requestIncreaseRate string - memoryProfile string - memoryProfileDuration time.Duration - cpuProfile string - cpuProfileDuration time.Duration - prometheusPushgatewayURL string - disableConsoleOutput bool - disableCompression bool - disableKeepalive bool -) +type BoomArgs struct { + SpawnCount int `json:"spawn-count,omitempty" yaml:"spawn-count,omitempty"` + SpawnRate float64 `json:"spawn-rate,omitempty" yaml:"spawn-rate,omitempty"` + MaxRPS int64 `json:"max-rps,omitempty" yaml:"max-rps,omitempty"` + LoopCount int64 `json:"loop-count,omitempty" yaml:"loop-count,omitempty"` + RequestIncreaseRate string `json:"request-increase-rate,omitempty" yaml:"request-increase-rate,omitempty"` + MemoryProfile string `json:"memory-profile,omitempty" yaml:"memory-profile,omitempty"` + MemoryProfileDuration time.Duration `json:"memory-profile-duration" yaml:"memory-profile-duration"` + CPUProfile string `json:"cpu-profile,omitempty" yaml:"cpu-profile,omitempty"` + CPUProfileDuration time.Duration `json:"cpu-profile-duration,omitempty" yaml:"cpu-profile-duration,omitempty"` + PrometheusPushgatewayURL string `json:"prometheus-gateway,omitempty" yaml:"prometheus-gateway,omitempty"` + DisableConsoleOutput bool `json:"disable-console-output,omitempty" yaml:"disable-console-output,omitempty"` + DisableCompression bool `json:"disable-compression,omitempty" yaml:"disable-compression,omitempty"` + DisableKeepalive bool `json:"disable-keepalive,omitempty" yaml:"disable-keepalive,omitempty"` + profile string +} + +var boomArgs BoomArgs func init() { rootCmd.AddCommand(boomCmd) - boomCmd.Flags().Int64Var(&maxRPS, "max-rps", 0, "Max RPS that boomer can generate, disabled by default.") - boomCmd.Flags().StringVar(&requestIncreaseRate, "request-increase-rate", "-1", "Request increase rate, disabled by default.") - boomCmd.Flags().IntVar(&spawnCount, "spawn-count", 1, "The number of users to spawn for load testing") - boomCmd.Flags().Float64Var(&spawnRate, "spawn-rate", 1, "The rate for spawning users") - boomCmd.Flags().Int64Var(&loopCount, "loop-count", -1, "The specify running cycles for load testing") - boomCmd.Flags().StringVar(&memoryProfile, "mem-profile", "", "Enable memory profiling.") - boomCmd.Flags().DurationVar(&memoryProfileDuration, "mem-profile-duration", 30*time.Second, "Memory profile duration.") - boomCmd.Flags().StringVar(&cpuProfile, "cpu-profile", "", "Enable CPU profiling.") - boomCmd.Flags().DurationVar(&cpuProfileDuration, "cpu-profile-duration", 30*time.Second, "CPU profile duration.") - boomCmd.Flags().StringVar(&prometheusPushgatewayURL, "prometheus-gateway", "", "Prometheus Pushgateway url.") - boomCmd.Flags().BoolVar(&disableConsoleOutput, "disable-console-output", false, "Disable console output.") - boomCmd.Flags().BoolVar(&disableCompression, "disable-compression", false, "Disable compression") - boomCmd.Flags().BoolVar(&disableKeepalive, "disable-keepalive", false, "Disable keepalive") + boomCmd.Flags().Int64Var(&boomArgs.MaxRPS, "max-rps", 0, "Max RPS that boomer can generate, disabled by default.") + boomCmd.Flags().StringVar(&boomArgs.RequestIncreaseRate, "request-increase-rate", "-1", "Request increase rate, disabled by default.") + boomCmd.Flags().IntVar(&boomArgs.SpawnCount, "spawn-count", 1, "The number of users to spawn for load testing") + boomCmd.Flags().Float64Var(&boomArgs.SpawnRate, "spawn-rate", 1, "The rate for spawning users") + boomCmd.Flags().Int64Var(&boomArgs.LoopCount, "loop-count", -1, "The specify running cycles for load testing") + boomCmd.Flags().StringVar(&boomArgs.MemoryProfile, "mem-profile", "", "Enable memory profiling.") + boomCmd.Flags().DurationVar(&boomArgs.MemoryProfileDuration, "mem-profile-duration", 30*time.Second, "Memory profile duration.") + boomCmd.Flags().StringVar(&boomArgs.CPUProfile, "cpu-profile", "", "Enable CPU profiling.") + boomCmd.Flags().DurationVar(&boomArgs.CPUProfileDuration, "cpu-profile-duration", 30*time.Second, "CPU profile duration.") + boomCmd.Flags().StringVar(&boomArgs.PrometheusPushgatewayURL, "prometheus-gateway", "", "Prometheus Pushgateway url.") + boomCmd.Flags().BoolVar(&boomArgs.DisableConsoleOutput, "disable-console-output", false, "Disable console output.") + boomCmd.Flags().BoolVar(&boomArgs.DisableCompression, "disable-compression", false, "Disable compression") + boomCmd.Flags().BoolVar(&boomArgs.DisableKeepalive, "disable-keepalive", false, "Disable keepalive") + boomCmd.Flags().StringVar(&boomArgs.profile, "profile", "", "profile for load testing") } diff --git a/hrp/cmd/build.go b/hrp/cmd/build.go new file mode 100644 index 00000000..3c8848e3 --- /dev/null +++ b/hrp/cmd/build.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/v4/hrp" +) + +var buildCmd = &cobra.Command{ + Use: "build $path ...", + Short: "build plugin for testing", + Long: `build python/go plugin for testing`, + Example: ` $ hrp build plugin/debugtalk.go + $ hrp build plugin/debugtalk.py`, + Args: cobra.ExactArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + setLogLevel(logLevel) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return hrp.BuildPlugin(args[0], output) + }, +} + +var output string + +func init() { + rootCmd.AddCommand(buildCmd) + + buildCmd.Flags().StringVarP(&output, "output", "o", "", "funplugin product output path, default: cwd") +} diff --git a/hrp/cmd/cli/main.go b/hrp/cmd/cli/main.go index f5c1010c..63f5c9bd 100644 --- a/hrp/cmd/cli/main.go +++ b/hrp/cmd/cli/main.go @@ -5,7 +5,7 @@ import ( "github.com/getsentry/sentry-go" - "github.com/httprunner/httprunner/hrp/cmd" + "github.com/httprunner/httprunner/v4/hrp/cmd" ) func main() { diff --git a/hrp/cmd/convert.go b/hrp/cmd/convert.go index 7ec89187..83ba88bb 100644 --- a/hrp/cmd/convert.go +++ b/hrp/cmd/convert.go @@ -2,47 +2,60 @@ package cmd import ( "errors" - "os" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/hrp/internal/convert" + "github.com/httprunner/httprunner/v4/hrp/internal/convert" ) var convertCmd = &cobra.Command{ Use: "convert $path...", - Short: "convert JSON/YAML testcases to pytest/gotest scripts", - Args: cobra.ExactValidArgs(1), + Short: "convert to JSON/YAML/gotest/pytest testcases", + Args: cobra.MinimumNArgs(1), PreRun: func(cmd *cobra.Command, args []string) { setLogLevel(logLevel) }, RunE: func(cmd *cobra.Command, args []string) error { - if !pytestFlag && !gotestFlag { - return errors.New("please specify convertion type") + var flagCount int + var outputType convert.OutputType + if toJSONFlag { + flagCount++ } - - var err error - if gotestFlag { - err = convert.Convert2TestScripts("gotest", args...) - } else { - err = convert.Convert2TestScripts("pytest", args...) + if toYAMLFlag { + flagCount++ + outputType = convert.OutputTypeYAML } - if err != nil { - log.Error().Err(err).Msg("convert test scripts failed") - os.Exit(1) + if toGoTestFlag { + flagCount++ + outputType = convert.OutputTypeGoTest } + if toPyTestFlag { + flagCount++ + outputType = convert.OutputTypePyTest + } + if flagCount > 1 { + return errors.New("please specify at most one conversion flag") + } + convert.Run(outputType, outputDir, profilePath, args) return nil }, } var ( - pytestFlag bool - gotestFlag bool + toJSONFlag bool + toYAMLFlag bool + toGoTestFlag bool + toPyTestFlag bool + outputDir string + profilePath string ) func init() { rootCmd.AddCommand(convertCmd) - convertCmd.Flags().BoolVar(&pytestFlag, "pytest", true, "convert to pytest scripts") - convertCmd.Flags().BoolVar(&gotestFlag, "gotest", false, "convert to gotest scripts (TODO)") + convertCmd.Flags().BoolVar(&toPyTestFlag, "to-pytest", false, "convert to pytest scripts") + convertCmd.Flags().BoolVar(&toGoTestFlag, "to-gotest", false, "convert to gotest scripts (TODO)") + convertCmd.Flags().BoolVar(&toJSONFlag, "to-json", false, "convert to JSON scripts (default)") + convertCmd.Flags().BoolVar(&toYAMLFlag, "to-yaml", false, "convert to YAML scripts") + convertCmd.Flags().StringVarP(&outputDir, "output-dir", "d", "", "specify output directory, default to the same dir with har file") + convertCmd.Flags().StringVarP(&profilePath, "profile", "p", "", "specify profile path to override headers and cookies") } diff --git a/hrp/cmd/har2case.go b/hrp/cmd/har2case.go deleted file mode 100644 index ee8380c1..00000000 --- a/hrp/cmd/har2case.go +++ /dev/null @@ -1,72 +0,0 @@ -package cmd - -import ( - "errors" - - "github.com/rs/zerolog/log" - "github.com/spf13/cobra" - - "github.com/httprunner/httprunner/hrp/internal/har2case" -) - -// har2caseCmd represents the har2case command -var har2caseCmd = &cobra.Command{ - Use: "har2case $har_path...", - Short: "convert HAR to json/yaml testcase files", - Long: `convert HAR to json/yaml testcase files`, - Args: cobra.MinimumNArgs(1), - PreRun: func(cmd *cobra.Command, args []string) { - setLogLevel(logLevel) - }, - RunE: func(cmd *cobra.Command, args []string) error { - var outputFiles []string - for _, arg := range args { - // must choose one - if !genYAMLFlag && !genJSONFlag { - return errors.New("please select convert format type") - } - var outputPath string - var err error - - har := har2case.NewHAR(arg) - - // specify output dir - if outputDir != "" { - har.SetOutputDir(outputDir) - } - - // specify profile - if profilePath != "" { - har.SetProfile(profilePath) - } - - // generate json/yaml files - if genYAMLFlag { - outputPath, err = har.GenYAML() - } else { - outputPath, err = har.GenJSON() // default - } - if err != nil { - return err - } - outputFiles = append(outputFiles, outputPath) - } - log.Info().Strs("output", outputFiles).Msg("convert testcase success") - return nil - }, -} - -var ( - genJSONFlag bool - genYAMLFlag bool - outputDir string - profilePath string -) - -func init() { - rootCmd.AddCommand(har2caseCmd) - har2caseCmd.Flags().BoolVarP(&genJSONFlag, "to-json", "j", true, "convert to JSON format") - har2caseCmd.Flags().BoolVarP(&genYAMLFlag, "to-yaml", "y", false, "convert to YAML format") - har2caseCmd.Flags().StringVarP(&outputDir, "output-dir", "d", "", "specify output directory, default to the same dir with har file") - har2caseCmd.Flags().StringVarP(&profilePath, "profile", "p", "", "specify profile path to override headers and cookies") -} diff --git a/hrp/cmd/pytest.go b/hrp/cmd/pytest.go index 8a5b4c5a..fd7732fb 100644 --- a/hrp/cmd/pytest.go +++ b/hrp/cmd/pytest.go @@ -3,7 +3,7 @@ package cmd import ( "github.com/spf13/cobra" - "github.com/httprunner/httprunner/hrp/internal/pytest" + "github.com/httprunner/httprunner/v4/hrp/internal/pytest" ) var pytestCmd = &cobra.Command{ diff --git a/hrp/cmd/root.go b/hrp/cmd/root.go index c19775d9..2909ea15 100644 --- a/hrp/cmd/root.go +++ b/hrp/cmd/root.go @@ -9,7 +9,7 @@ import ( "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/hrp/internal/version" + "github.com/httprunner/httprunner/v4/hrp/internal/version" ) // rootCmd represents the base command when called without any subcommands @@ -33,7 +33,7 @@ Website: https://httprunner.com Github: https://github.com/httprunner/httprunner Copyright 2017 debugtalk`, PersistentPreRun: func(cmd *cobra.Command, args []string) { - var noColor = false + noColor := false if runtime.GOOS == "windows" { noColor = true } diff --git a/hrp/cmd/run.go b/hrp/cmd/run.go index 17f54014..500e595b 100644 --- a/hrp/cmd/run.go +++ b/hrp/cmd/run.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" - "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/v4/hrp" ) // runCmd represents the run command @@ -35,6 +35,9 @@ var runCmd = &cobra.Command{ if !requestsLogOff { runner.SetRequestsLogOn() } + if httpStatOn { + runner.SetHTTPStatOn() + } if pluginLogOn { runner.SetPluginLogOn() } @@ -51,6 +54,7 @@ var runCmd = &cobra.Command{ var ( continueOnFailure bool requestsLogOff bool + httpStatOn bool pluginLogOn bool proxyUrl string saveTests bool @@ -61,6 +65,7 @@ func init() { rootCmd.AddCommand(runCmd) runCmd.Flags().BoolVarP(&continueOnFailure, "continue-on-failure", "c", false, "continue running next step when failure occurs") runCmd.Flags().BoolVar(&requestsLogOff, "log-requests-off", false, "turn off request & response details logging") + runCmd.Flags().BoolVar(&httpStatOn, "http-stat", false, "turn on HTTP latency stat (DNSLookup, TCP Connection, etc.)") runCmd.Flags().BoolVar(&pluginLogOn, "log-plugin", false, "turn on plugin logging") runCmd.Flags().StringVarP(&proxyUrl, "proxy-url", "p", "", "set proxy url") runCmd.Flags().BoolVarP(&saveTests, "save-tests", "s", false, "save tests summary") diff --git a/hrp/cmd/scaffold.go b/hrp/cmd/scaffold.go index 5f9eb945..c51281ed 100644 --- a/hrp/cmd/scaffold.go +++ b/hrp/cmd/scaffold.go @@ -7,13 +7,14 @@ import ( "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/hrp/internal/scaffold" + "github.com/httprunner/httprunner/v4/hrp/internal/scaffold" ) var scaffoldCmd = &cobra.Command{ - Use: "startproject $project_name", - Short: "create a scaffold project", - Args: cobra.ExactValidArgs(1), + Use: "startproject $project_name", + Aliases: []string{"scaffold"}, + Short: "create a scaffold project", + Args: cobra.ExactValidArgs(1), PreRun: func(cmd *cobra.Command, args []string) { setLogLevel(logLevel) }, @@ -23,7 +24,9 @@ var scaffoldCmd = &cobra.Command{ } var pluginType scaffold.PluginType - if ignorePlugin { + if empty { + pluginType = scaffold.Empty + } else if ignorePlugin { pluginType = scaffold.Ignore } else if genGoPlugin { pluginType = scaffold.Go @@ -42,6 +45,7 @@ var scaffoldCmd = &cobra.Command{ } var ( + empty bool ignorePlugin bool genPythonPlugin bool genGoPlugin bool @@ -54,4 +58,5 @@ func init() { scaffoldCmd.Flags().BoolVar(&genPythonPlugin, "py", true, "generate hashicorp python plugin") scaffoldCmd.Flags().BoolVar(&genGoPlugin, "go", false, "generate hashicorp go plugin") scaffoldCmd.Flags().BoolVar(&ignorePlugin, "ignore-plugin", false, "ignore function plugin") + scaffoldCmd.Flags().BoolVar(&empty, "empty", false, "generate empty project") } diff --git a/hrp/cmd/wiki.go b/hrp/cmd/wiki.go new file mode 100644 index 00000000..7774a740 --- /dev/null +++ b/hrp/cmd/wiki.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/v4/hrp/internal/wiki" +) + +var wikiCmd = &cobra.Command{ + Use: "wiki", + Aliases: []string{"info", "docs", "doc"}, + Short: "visit https://httprunner.com", + PreRun: func(cmd *cobra.Command, args []string) { + setLogLevel(logLevel) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return wiki.OpenWiki() + }, +} + +func init() { + rootCmd.AddCommand(wikiCmd) +} diff --git a/hrp/config.go b/hrp/config.go index 2b84d576..404e51c9 100644 --- a/hrp/config.go +++ b/hrp/config.go @@ -3,13 +3,14 @@ package hrp import ( "reflect" - "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" ) // NewConfig returns a new constructed testcase config with specified testcase name. func NewConfig(name string) *TConfig { return &TConfig{ Name: name, + Environs: make(map[string]string), Variables: make(map[string]interface{}), } } @@ -19,9 +20,10 @@ func NewConfig(name string) *TConfig { type TConfig struct { Name string `json:"name" yaml:"name"` // required Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"` - BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty"` - Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` - Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` + BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty"` // deprecated in v4.1, moved to env + Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` // public request headers + Environs map[string]string `json:"environs,omitempty" yaml:"environs,omitempty"` // environment variables + Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` // global variables Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty"` ParametersSetting *TParamsConfig `json:"parameters_setting,omitempty" yaml:"parameters_setting,omitempty"` ThinkTimeSetting *ThinkTimeConfig `json:"think_time,omitempty" yaml:"think_time,omitempty"` @@ -161,6 +163,4 @@ const ( thinkTimeDefaultMultiply = 1 ) -var ( - thinkTimeDefaultRandom = map[string]float64{"min_percentage": 0.5, "max_percentage": 1.5} -) +var thinkTimeDefaultRandom = map[string]float64{"min_percentage": 0.5, "max_percentage": 1.5} diff --git a/hrp/internal/boomer/boomer.go b/hrp/internal/boomer/boomer.go index 83459a4f..cc424b12 100644 --- a/hrp/internal/boomer/boomer.go +++ b/hrp/internal/boomer/boomer.go @@ -7,11 +7,26 @@ import ( "syscall" "time" + "github.com/pkg/errors" "github.com/rs/zerolog/log" ) +// Mode is the running mode of boomer, both standalone and distributed are supported. +type Mode int + +const ( + // DistributedMasterMode requires being connected by each worker. + DistributedMasterMode Mode = iota + // DistributedWorkerMode requires connecting to a master. + DistributedWorkerMode + // StandaloneMode will run without a master. + StandaloneMode +) + // A Boomer is used to run tasks. type Boomer struct { + mode Mode + localRunner *localRunner cpuProfile string @@ -24,9 +39,39 @@ type Boomer struct { disableCompression bool } +// SetMode only accepts boomer.DistributedMasterMode、boomer.DistributedWorkerMode and boomer.StandaloneMode. +func (b *Boomer) SetMode(mode Mode) { + switch mode { + case DistributedMasterMode: + b.mode = DistributedMasterMode + case DistributedWorkerMode: + b.mode = DistributedWorkerMode + case StandaloneMode: + b.mode = StandaloneMode + default: + log.Error().Err(errors.New("Invalid mode, ignored!")) + } +} + +// GetMode returns boomer operating mode +func (b *Boomer) GetMode() string { + switch b.mode { + case DistributedMasterMode: + return "master" + case DistributedWorkerMode: + return "worker" + case StandaloneMode: + return "standalone" + default: + log.Error().Err(errors.New("Invalid mode, ignored!")) + return "" + } +} + // NewStandaloneBoomer returns a new Boomer, which can run without master. func NewStandaloneBoomer(spawnCount int, spawnRate float64) *Boomer { return &Boomer{ + mode: StandaloneMode, localRunner: newLocalRunner(spawnCount, spawnRate), } } @@ -170,3 +215,7 @@ func (b *Boomer) GetSpawnDoneChan() chan struct{} { func (b *Boomer) GetSpawnCount() int { return b.localRunner.spawnCount } + +func (b *Boomer) ResetStartTime() { + b.localRunner.stats.total.resetStartTime() +} diff --git a/hrp/internal/boomer/output.go b/hrp/internal/boomer/output.go index 79284cf7..db77b053 100644 --- a/hrp/internal/boomer/output.go +++ b/hrp/internal/boomer/output.go @@ -10,11 +10,12 @@ import ( "github.com/google/uuid" "github.com/olekukonko/tablewriter" + "github.com/pkg/errors" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/push" "github.com/rs/zerolog/log" - "github.com/httprunner/httprunner/hrp/internal/json" + "github.com/httprunner/httprunner/v4/hrp/internal/json" ) // Output is primarily responsible for printing test results to different destinations @@ -37,8 +38,7 @@ type Output interface { } // ConsoleOutput is the default output for standalone mode. -type ConsoleOutput struct { -} +type ConsoleOutput struct{} // NewConsoleOutput returns a ConsoleOutput. func NewConsoleOutput() *ConsoleOutput { @@ -102,12 +102,10 @@ func getTotalFailRatio(totalRequests, totalFailures int64) (failRatio float64) { // OnStart of ConsoleOutput has nothing to do. func (o *ConsoleOutput) OnStart() { - } // OnStop of ConsoleOutput has nothing to do. func (o *ConsoleOutput) OnStop() { - } // OnEvent will print to the console. @@ -264,10 +262,9 @@ func deserializeStatsEntry(stat interface{}) (entryOutput *statsEntryOutput, err var duration float64 if entry.Name == "Total" { - duration = float64(entry.LastRequestTimestamp - entry.StartTime) - // fix: avoid divide by zero - if duration < 1 { - duration = 1 + duration = float64(entry.LastRequestTimestamp-entry.StartTime) / 1e3 + if duration == 0 { + return nil, errors.New("no step specified") } } else { duration = float64(reportStatsInterval / time.Second) @@ -474,10 +471,12 @@ var ( ) // NewPrometheusPusherOutput returns a PrometheusPusherOutput. -func NewPrometheusPusherOutput(gatewayURL, jobName string) *PrometheusPusherOutput { +func NewPrometheusPusherOutput(gatewayURL, jobName string, mode string) *PrometheusPusherOutput { nodeUUID, _ := uuid.NewUUID() return &PrometheusPusherOutput{ - pusher: push.New(gatewayURL, jobName).Grouping("instance", nodeUUID.String()), + pusher: push.New(gatewayURL, jobName). + Grouping("instance", nodeUUID.String()). + Grouping("mode", mode), } } diff --git a/hrp/internal/boomer/runner.go b/hrp/internal/boomer/runner.go index 25ad1bbf..68109901 100644 --- a/hrp/internal/boomer/runner.go +++ b/hrp/internal/boomer/runner.go @@ -11,7 +11,6 @@ import ( "time" "github.com/olekukonko/tablewriter" - "github.com/rs/zerolog/log" ) @@ -154,7 +153,7 @@ func (r *runner) reportTestResult() { if err != nil { return } - duration := time.Duration(entryTotalOutput.LastRequestTimestamp-entryTotalOutput.StartTime) * time.Second + duration := time.Duration(entryTotalOutput.LastRequestTimestamp-entryTotalOutput.StartTime) * time.Millisecond currentTime := time.Now() println(fmt.Sprint("=========================================== Statistics Summary ==========================================")) println(fmt.Sprintf("Current time: %s, Users: %v, Duration: %v, Accumulated Transactions: %d Passed, %d Failed", diff --git a/hrp/internal/boomer/stats.go b/hrp/internal/boomer/stats.go index b15c655d..c1bbcfe8 100644 --- a/hrp/internal/boomer/stats.go +++ b/hrp/internal/boomer/stats.go @@ -1,9 +1,10 @@ package boomer import ( + "sync/atomic" "time" - "github.com/httprunner/httprunner/hrp/internal/json" + "github.com/httprunner/httprunner/v4/hrp/internal/json" ) type transaction struct { @@ -101,8 +102,6 @@ func (s *requestStats) get(name string, method string) (entry *statsEntry) { newEntry := &statsEntry{ Name: name, Method: method, - NumReqsPerSec: make(map[int64]int64), - NumFailPerSec: make(map[int64]int64), ResponseTimes: make(map[int64]int64), } s.entries[name+method] = newEntry @@ -171,10 +170,6 @@ type statsEntry struct { MinResponseTime int64 `json:"min_response_time"` // Maximum response time MaxResponseTime int64 `json:"max_response_time"` - // A {second => request_count} dict that holds the number of requests made per second - NumReqsPerSec map[int64]int64 `json:"num_reqs_per_sec"` - // A (second => failure_count) dict that hold the number of failures per second - NumFailPerSec map[int64]int64 `json:"num_fail_per_sec"` // A {response_time => count} dict that holds the response time distribution of all the requests // The keys (the response time in ms) are rounded to store 1, 2, ... 9, 10, 20. .. 90, // 100, 200 .. 900, 1000, 2000 ... 9000, in order to save memory. @@ -191,17 +186,19 @@ type statsEntry struct { NumNoneRequests int64 `json:"num_none_requests"` } +func (s *statsEntry) resetStartTime() { + atomic.StoreInt64(&s.StartTime, time.Duration(time.Now().UnixNano()).Milliseconds()) +} + func (s *statsEntry) reset() { - s.StartTime = time.Now().Unix() + atomic.StoreInt64(&s.StartTime, time.Duration(time.Now().UnixNano()).Milliseconds()) s.NumRequests = 0 s.NumFailures = 0 s.TotalResponseTime = 0 s.ResponseTimes = make(map[int64]int64) s.MinResponseTime = 0 s.MaxResponseTime = 0 - s.LastRequestTimestamp = time.Now().Unix() - s.NumReqsPerSec = make(map[int64]int64) - s.NumFailPerSec = make(map[int64]int64) + s.LastRequestTimestamp = time.Duration(time.Now().UnixNano()).Milliseconds() s.TotalContentLength = 0 } @@ -215,15 +212,7 @@ func (s *statsEntry) log(responseTime int64, contentLength int64) { } func (s *statsEntry) logTimeOfRequest() { - key := time.Now().Unix() - _, ok := s.NumReqsPerSec[key] - if !ok { - s.NumReqsPerSec[key] = 1 - } else { - s.NumReqsPerSec[key]++ - } - - s.LastRequestTimestamp = key + s.LastRequestTimestamp = time.Duration(time.Now().UnixNano()).Milliseconds() } func (s *statsEntry) logResponseTime(responseTime int64) { @@ -267,13 +256,6 @@ func (s *statsEntry) logResponseTime(responseTime int64) { func (s *statsEntry) logFailures() { s.NumFailures++ - key := time.Now().Unix() - _, ok := s.NumFailPerSec[key] - if !ok { - s.NumFailPerSec[key] = 1 - } else { - s.NumFailPerSec[key]++ - } } func (s *statsEntry) serialize() map[string]interface{} { diff --git a/hrp/internal/boomer/ulimit.go b/hrp/internal/boomer/ulimit.go index b83585ea..504a534d 100644 --- a/hrp/internal/boomer/ulimit.go +++ b/hrp/internal/boomer/ulimit.go @@ -23,6 +23,7 @@ func SetUlimit(limit uint64) { } rLimit.Cur = limit + rLimit.Max = limit log.Info().Uint64("limit", rLimit.Cur).Msg("set current ulimit") err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit) if err != nil { diff --git a/hrp/internal/builtin/assertion.go b/hrp/internal/builtin/assertion.go index 96fd4310..20473727 100644 --- a/hrp/internal/builtin/assertion.go +++ b/hrp/internal/builtin/assertion.go @@ -45,6 +45,7 @@ var Assertions = map[string]func(t assert.TestingT, actual interface{}, expected "contained_by": ContainedBy, "str_eq": StringEqual, "string_equals": StringEqual, + "equal_fold": EqualFold, "regex_match": RegexMatch, } @@ -157,6 +158,12 @@ func ContainedBy(t assert.TestingT, actual, expected interface{}, msgAndArgs ... } func StringEqual(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + a := fmt.Sprintf("%v", actual) + e := fmt.Sprintf("%v", expected) + return assert.True(t, a == e, msgAndArgs) +} + +func EqualFold(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { if !assert.IsType(t, "string", actual, msgAndArgs) { return false } diff --git a/hrp/internal/builtin/assertion_test.go b/hrp/internal/builtin/assertion_test.go index aa7d4bde..3a2ebc89 100644 --- a/hrp/internal/builtin/assertion_test.go +++ b/hrp/internal/builtin/assertion_test.go @@ -158,6 +158,27 @@ func TestContainedBy(t *testing.T) { } func TestStringEqual(t *testing.T) { + testData := []struct { + raw interface{} + expected interface{} + }{ + {"abcd", "abcd"}, + {"0", 0}, + {"123", 123}, + // {"123.0", 123.0}, // FIXME + {"12.3", 12.3}, + {"-12.3", -12.3}, + {"-123", -123}, + } + + for _, data := range testData { + if !assert.True(t, StringEqual(t, data.raw, data.expected)) { + t.Fatal() + } + } +} + +func TestEqualFold(t *testing.T) { testData := []struct { raw interface{} expected interface{} @@ -168,7 +189,7 @@ func TestStringEqual(t *testing.T) { } for _, data := range testData { - if !assert.True(t, StringEqual(t, data.raw, data.expected)) { + if !assert.True(t, EqualFold(t, data.raw, data.expected)) { t.Fatal() } } diff --git a/hrp/internal/builtin/utils.go b/hrp/internal/builtin/utils.go index 98c63c6f..8f3ca391 100644 --- a/hrp/internal/builtin/utils.go +++ b/hrp/internal/builtin/utils.go @@ -13,12 +13,12 @@ import ( "strconv" "strings" + "github.com/httprunner/funplugin/shared" "github.com/pkg/errors" "github.com/rs/zerolog/log" "gopkg.in/yaml.v3" - "github.com/httprunner/funplugin/shared" - "github.com/httprunner/httprunner/hrp/internal/json" + "github.com/httprunner/httprunner/v4/hrp/internal/json" ) func Dump2JSON(data interface{}, path string) error { @@ -28,8 +28,19 @@ func Dump2JSON(data interface{}, path string) error { return err } log.Info().Str("path", path).Msg("dump data to json") - file, _ := json.MarshalIndent(data, "", " ") - err = os.WriteFile(path, file, 0644) + + // init json encoder + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(buffer) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + + err = encoder.Encode(data) + if err != nil { + return err + } + + err = os.WriteFile(path, buffer.Bytes(), 0o644) if err != nil { log.Error().Err(err).Msg("dump json path failed") return err @@ -56,7 +67,7 @@ func Dump2YAML(data interface{}, path string) error { return err } - err = os.WriteFile(path, buffer.Bytes(), 0644) + err = os.WriteFile(path, buffer.Bytes(), 0o644) if err != nil { log.Error().Err(err).Msg("dump yaml path failed") return err @@ -281,11 +292,12 @@ 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") - file, err := readFile(path) + file, err := ReadFile(path) if err != nil { return errors.Wrap(err, "read file failed") } - + // remove BOM at the beginning of file + file = bytes.TrimLeft(file, "\xef\xbb\xbf") ext := filepath.Ext(path) switch ext { case ".json", ".har": @@ -294,15 +306,47 @@ func LoadFile(path string, structObj interface{}) (err error) { err = decoder.Decode(structObj) case ".yaml", ".yml": err = yaml.Unmarshal(file, structObj) + case ".env": + err = parseEnvContent(file, structObj) default: err = ErrUnsupportedFileExt } return err } +func parseEnvContent(file []byte, obj interface{}) error { + envMap := obj.(map[string]string) + lines := strings.Split(string(file), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + // empty line or comment line + continue + } + var kv []string + if strings.Contains(line, "=") { + kv = strings.SplitN(line, "=", 2) + } else if strings.Contains(line, ":") { + kv = strings.SplitN(line, ":", 2) + } + if len(kv) != 2 { + return errors.New(".env format error") + } + + key := strings.TrimSpace(kv[0]) + value := strings.TrimSpace(kv[1]) + envMap[key] = value + + // set env + log.Info().Str("key", key).Msg("set env") + os.Setenv(key, value) + } + return nil +} + func loadFromCSV(path string) []map[string]interface{} { log.Info().Str("path", path).Msg("load csv file") - file, err := readFile(path) + file, err := ReadFile(path) if err != nil { log.Error().Err(err).Msg("read csv file failed") os.Exit(1) @@ -328,7 +372,7 @@ func loadFromCSV(path string) []map[string]interface{} { func loadMessage(path string) []byte { log.Info().Str("path", path).Msg("load message file") - file, err := readFile(path) + file, err := ReadFile(path) if err != nil { log.Error().Err(err).Msg("read message file failed") os.Exit(1) @@ -336,7 +380,7 @@ func loadMessage(path string) []byte { return file } -func readFile(path string) ([]byte, error) { +func ReadFile(path string) ([]byte, error) { var err error path, err = filepath.Abs(path) if err != nil { @@ -351,3 +395,9 @@ func readFile(path string) ([]byte, error) { } return file, nil } + +func GetOutputNameWithoutExtension(path string) string { + base := filepath.Base(path) + ext := filepath.Ext(base) + return base[0:len(base)-len(ext)] + "_test" +} diff --git a/hrp/internal/convert/README.md b/hrp/internal/convert/README.md new file mode 100644 index 00000000..da426350 --- /dev/null +++ b/hrp/internal/convert/README.md @@ -0,0 +1,77 @@ +# hrp convert + +## 快速上手 + +```shell +$ hrp convert -h +convert to JSON/YAML/gotest/pytest testcases + +Usage: + hrp convert $path... [flags] + +Flags: + -h, --help help for convert + -d, --output-dir string specify output directory, default to the same dir with har file + -p, --profile string specify profile path to override headers (except for auto-generated headers) and cookies + --to-gotest convert to gotest scripts (TODO) + --to-json convert to JSON scripts (default true) + --to-pytest convert to pytest scripts + --to-yaml convert to YAML scripts + +Global Flags: + --log-json set log to json format + -l, --log-level string set log level (default "INFO") +``` + +`hrp convert` 指令用于将 HAR/Postman/JMeter/Swagger 文件或 curl/Apache ab 指令转化为 JSON/YAML/gotest/pytest 形态的测试用例,同时也支持测试用例各个形态之间的相互转化。 + +该指令所有选项的详细说明如下: + +1. `--to-json / --to-yaml / --to-gotest / --to-pytest` 用于将输入转化为对应形态的测试用例,四个选项中最多只能指定一个,如果不指定则默认会将输入转化为 JSON 形态的测试用例 +2. `--output-dir` 后接测试用例的期望输出目录的路径,用于将转换生成的测试用例输出到对应的文件夹 +3. `--profile` 后接 profile 配置文件的路径,目前支持替换(不存在则会创建)或者覆盖输入的外部脚本/测试用例中的 `Headers` 和 `Cookies` 信息,profile 文件的后缀可以为 `json/yaml/yml`,下面给出两类 profile 配置文件的示例: +- 根据 profile 替换指定的 `Headers` 和 `Cookies` 信息 +```yaml +headers: + Header1: "this header will be created or updated" +cookies: + Cookie1: "this cookie will be created or updated" +``` +- 根据 profile 覆盖原有的 `Headers` 和 `Cookies` 信息 +```yaml +override: true +headers: + Header1: "all original headers will be overridden" +cookies: + Cookie1: "all original cookies will be overridden" +``` + +## 注意事项 + +1. 输出的测试用例文件名格式为 `Postman 工程文件名称(不带拓展名)` + `_test` + `.json/.yaml/.go/.py 后缀`,如果该文件已经存在则会进行覆盖 +2. `hrp convert` 可以自动识别输入类型,因此不需要通过选项来手动制定输入类型,如遇到无法识别、不支持或转换失败的情况,则会输出错误日志并跳过,不会影响其他转换过程的正常进行 +3. 在 profile 文件中,指定 `override` 字段为 `false/true` 可以选择修改模式为替换/覆盖。需要注意的是,如果不指定该字段则 profile 的默认修改模式为替换模式 +4. 输入为 JSON/YAML 测试用例时,良好兼容 Golang/Python 双引擎的请求体、断言格式细微差异,输出的 JSON/YAML 则统一采用 Golang 引擎的风格 + + +## 转换流程图 + +`hrp convert` 的转换过程流程图如下: +![flow chart](asset/flowgram.png) + +## 开发进度 + +`hrp convert` 当前的开发进度如下: + +| from \ to | JSON | YAML | GoTest | PyTest | +|:---------:|:----:|:----:|:------:|:------:| +| HAR | ✅ | ✅ | ❌ | ✅ | +| Postman | ✅ | ✅ | ❌ | ✅ | +| JMeter | ❌ | ❌ | ❌ | ❌ | +| Swagger | ❌ | ❌ | ❌ | ❌ | +| curl | ❌ | ❌ | ❌ | ❌ | +| Apache ab | ❌ | ❌ | ❌ | ❌ | +| JSON | ✅ | ✅ | ❌ | ✅ | +| YAML | ✅ | ✅ | ❌ | ✅ | +| GoTest | ❌ | ❌ | ❌ | ❌ | +| PyTest | ❌ | ❌ | ❌ | ❌ | \ No newline at end of file diff --git a/hrp/internal/convert/asset/flowgram.png b/hrp/internal/convert/asset/flowgram.png new file mode 100644 index 00000000..3e676ec7 Binary files /dev/null and b/hrp/internal/convert/asset/flowgram.png differ diff --git a/hrp/internal/convert/converter.go b/hrp/internal/convert/converter.go new file mode 100644 index 00000000..63e07690 --- /dev/null +++ b/hrp/internal/convert/converter.go @@ -0,0 +1,376 @@ +package convert + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + "reflect" + + "github.com/go-openapi/spec" + "github.com/pkg/errors" + "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/sdk" +) + +const ( + suffixJSON = ".json" + suffixYAML = ".yaml" + suffixGoTest = ".go" + suffixPyTest = ".py" +) + +type InputType int + +const ( + InputTypeUnknown InputType = iota // default input type: unknown + InputTypeHAR + InputTypePostman + InputTypeSwagger + InputTypeJMeter + InputTypeJSON + InputTypeYAML + InputTypeGoTest + InputTypePyTest +) + +func (inputType InputType) String() string { + switch inputType { + case InputTypeHAR: + return "har" + case InputTypePostman: + return "postman" + case InputTypeSwagger: + return "swagger" + case InputTypeJMeter: + return "jmeter" + case InputTypeJSON: + return "json testcase" + case InputTypeYAML: + return "yaml testcase" + case InputTypeGoTest: + return "gotest script" + case InputTypePyTest: + return "pytest script" + default: + return "unknown" + } +} + +type OutputType int + +const ( + OutputTypeJSON OutputType = iota // default output type: JSON + OutputTypeYAML + OutputTypeGoTest + OutputTypePyTest +) + +func (outputType OutputType) String() string { + switch outputType { + case OutputTypeYAML: + return "yaml" + case OutputTypeGoTest: + return "gotest" + case OutputTypePyTest: + return "pytest" + default: + return "json" + } +} + +// TCaseConverter holds the common properties of case converter +type TCaseConverter struct { + InputPath string + OutputDir string + Profile *Profile + InputType InputType + OutputType OutputType + CaseHAR *CaseHar + CasePostman *CasePostman + CaseSwagger *spec.Swagger + TCase *hrp.TCase +} + +// Profile is used to override or update(create if not existed) original headers and cookies +type Profile struct { + Override bool `json:"override" yaml:"override"` + Headers map[string]string `json:"headers" yaml:"headers"` + Cookies map[string]string `json:"cookies" yaml:"cookies"` +} + +func NewTCaseConverter(path string) (tCaseConverter *TCaseConverter) { + tCaseConverter = &TCaseConverter{ + InputPath: path, + InputType: InputTypeUnknown, + } + extName := filepath.Ext(path) + if extName == "" { + log.Warn().Msg("extension name should be specified") + return + } + var err error + switch extName { + case ".har": + caseHAR := new(CaseHar) + err = builtin.LoadFile(path, caseHAR) + if err == nil && !reflect.ValueOf(*caseHAR).IsZero() { + tCaseConverter.InputType = InputTypeHAR + tCaseConverter.CaseHAR = caseHAR + } + case ".json": + tCase := new(hrp.TCase) + err = builtin.LoadFile(path, tCase) + if err == nil && !reflect.ValueOf(*tCase).IsZero() { + tCaseConverter.InputType = InputTypeJSON + tCaseConverter.TCase = tCase + break + } + casePostman := new(CasePostman) + err = builtin.LoadFile(path, casePostman) + // deal with postman field name conflict with swagger + descriptionBackup := casePostman.Info.Description + casePostman.Info.Description = "" + if err == nil && !reflect.ValueOf(*casePostman).IsZero() { + tCaseConverter.InputType = InputTypePostman + casePostman.Info.Description = descriptionBackup + tCaseConverter.CasePostman = casePostman + break + } + caseSwagger := new(spec.Swagger) + err = builtin.LoadFile(path, caseSwagger) + if err == nil && !reflect.ValueOf(*caseSwagger).IsZero() { + tCaseConverter.InputType = InputTypeSwagger + tCaseConverter.CaseSwagger = caseSwagger + } + case ".yaml", ".yml": + tCase := new(hrp.TCase) + err = builtin.LoadFile(path, tCase) + if err == nil && !reflect.ValueOf(*tCase).IsZero() { + tCaseConverter.InputType = InputTypeYAML + tCaseConverter.TCase = tCase + break + } + caseSwagger := new(spec.Swagger) + err = builtin.LoadFile(path, caseSwagger) + if err == nil && !reflect.ValueOf(*caseSwagger).IsZero() { + tCaseConverter.InputType = InputTypeSwagger + tCaseConverter.CaseSwagger = caseSwagger + } + case ".go": // TODO + tCaseConverter.InputType = InputTypeGoTest + case ".py": // TODO + tCaseConverter.InputType = InputTypePyTest + case ".jmx": // TODO + tCaseConverter.InputType = InputTypeJMeter + default: + log.Warn(). + Str("input path", tCaseConverter.InputPath). + Msgf("unsupported file type: %v", extName) + } + if tCaseConverter.InputType != InputTypeUnknown { + log.Info(). + Str("input path", tCaseConverter.InputPath). + Msgf("load case as: %s", tCaseConverter.InputType.String()) + } else { + log.Error().Err(err). + Str("input path", tCaseConverter.InputPath). + Msgf("failed to load case") + } + return +} + +func (c *TCaseConverter) SetProfile(path string) { + log.Info().Str("input path", c.InputPath).Str("profile", path).Msg("set profile") + profile := new(Profile) + err := builtin.LoadFile(path, profile) + if err != nil { + log.Warn().Str("path", path). + Msg("failed to load profile, ignore!") + return + } + c.Profile = profile +} + +func (c *TCaseConverter) SetOutputDir(dir string) { + log.Info().Str("input path", c.InputPath).Str("output directory", dir).Msg("set output directory") + c.OutputDir = dir +} + +func (c *TCaseConverter) genOutputPath(suffix string) string { + outFileFullName := builtin.GetOutputNameWithoutExtension(c.InputPath) + suffix + if c.OutputDir != "" { + return filepath.Join(c.OutputDir, outFileFullName) + } else { + return filepath.Join(filepath.Dir(c.InputPath), outFileFullName) + } + // TODO avoid outFileFullName conflict? +} + +func (c *TCaseConverter) ToPyTest() (string, error) { + script := convertConfig(c.TCase.Config) + println(script) + return script, nil +} + +func convertConfig(config *hrp.TConfig) string { + script := fmt.Sprintf("Config('%s')", config.Name) + + if config.Variables != nil { + script += fmt.Sprintf(".variables(**{%v})", config.Variables) + } + if config.BaseURL != "" { + script += fmt.Sprintf(".base_url('%s')", config.BaseURL) + } + if config.Export != nil { + script += fmt.Sprintf(".export(*%v)", config.Export) + } + script += fmt.Sprintf(".verify(%v)", config.Verify) + + return script +} + +func (c *TCaseConverter) ToGoTest() (string, error) { + return "", nil +} + +// ICaseConverter represents all kinds of case converters which could convert case into JSON/YAML/gotest/pytest format +type ICaseConverter interface { + Struct() *TCaseConverter + ToJSON() (string, error) + ToYAML() (string, error) + ToGoTest() (string, error) + ToPyTest() (string, error) +} + +func Run(outputType OutputType, outputDir, profilePath string, args []string) { + // report event + sdk.SendEvent(sdk.EventTracking{ + Category: "ConvertTests", + Action: fmt.Sprintf("hrp convert --to-%s", outputType.String()), + }) + + // identify input and load converters + var iCaseConverters []ICaseConverter + for _, arg := range args { + tCaseConverter := NewTCaseConverter(arg) + tCaseConverter.OutputType = outputType + if outputDir != "" { + tCaseConverter.SetOutputDir(outputDir) + } + if profilePath != "" { + tCaseConverter.SetProfile(profilePath) + } + switch tCaseConverter.InputType { + case InputTypeHAR: + iCaseConverters = append(iCaseConverters, NewConverterHAR(tCaseConverter)) + case InputTypePostman: + iCaseConverters = append(iCaseConverters, NewConverterPostman(tCaseConverter)) + case InputTypeJSON: + iCaseConverters = append(iCaseConverters, NewConverterJSON(tCaseConverter)) + case InputTypeYAML: + iCaseConverters = append(iCaseConverters, NewConverterYAML(tCaseConverter)) + case InputTypeSwagger, InputTypeJMeter, InputTypeGoTest, InputTypePyTest: + log.Warn(). + Str("input path", tCaseConverter.InputPath). + Msg("case type not supported yet, ignore!") + default: + log.Warn(). + Str("input path", tCaseConverter.InputPath). + Msg("unknown case type, ignore!") + } + } + + // start converting + var outputFiles []string + var err error + for _, iCaseConverter := range iCaseConverters { + log.Info().Str("input path", iCaseConverter.Struct().InputPath).Msg("start converting") + var outputFile string + switch iCaseConverter.Struct().OutputType { + case OutputTypeYAML: + outputFile, err = iCaseConverter.ToYAML() + case OutputTypeGoTest: + outputFile, err = iCaseConverter.ToGoTest() + case OutputTypePyTest: + outputFile, err = iCaseConverter.ToPyTest() + default: + outputFile, err = iCaseConverter.ToJSON() + } + if err != nil { + log.Error().Err(err). + Str("input path", iCaseConverter.Struct().InputPath). + Msg("error occurs during converting") + continue + } + outputFiles = append(outputFiles, outputFile) + } + log.Info().Strs("output files", outputFiles).Msg("conversion completed") +} + +func makeTestCaseFromJSONYAML(iCaseConverter ICaseConverter) (*hrp.TCase, error) { + tCase := iCaseConverter.Struct().TCase + if tCase == nil { + return nil, errors.Errorf("empty json/yaml testcase occurs") + } + profile := iCaseConverter.Struct().Profile + if profile == nil { + return tCase, nil + } + for _, step := range tCase.TestSteps { + // override original headers and cookies + if profile.Override { + step.Request.Headers = make(map[string]string) + step.Request.Cookies = make(map[string]string) + } + // update (create if not existed) original headers and cookies + if step.Request.Headers == nil { + step.Request.Headers = make(map[string]string) + } + if step.Request.Cookies == nil { + step.Request.Cookies = make(map[string]string) + } + for k, v := range profile.Headers { + step.Request.Headers[k] = v + } + for k, v := range profile.Cookies { + step.Request.Cookies[k] = v + } + } + return tCase, nil +} + +func convertToPyTest(iCaseConverter ICaseConverter) (string, error) { + // convert to temporary json testcase + jsonPath, err := iCaseConverter.ToJSON() + inputType := iCaseConverter.Struct().InputType + if err != nil { + return "", errors.Wrapf(err, "(%s -> pytest step 1) failed to convert to temporary json testcase", inputType.String()) + } + defer func() { + if jsonPath != "" { + if err = os.Remove(jsonPath); err != nil { + log.Error().Err(err).Msgf("(%s -> pytest step defer) failed to clean temporary json testcase", inputType.String()) + } + } + }() + + // convert from temporary json testcase to pytest + converterJSON := NewConverterJSON(NewTCaseConverter(jsonPath)) + pyTestPath, err := converterJSON.MakePyTestScript() + if err != nil { + return "", errors.Wrap(err, "(json -> pytest step 2) failed to convert from temporary json testcase to pytest ") + } + + // rename resultant pytest + renamedPyTestPath := iCaseConverter.Struct().genOutputPath(suffixPyTest) + err = os.Rename(pyTestPath, renamedPyTestPath) + if err != nil { + log.Error().Err(err).Msg("(json -> pytest step 3) failed to rename the resultant pytest file") + return pyTestPath, nil + } + return renamedPyTestPath, nil +} diff --git a/hrp/internal/convert/converter_gotest.go b/hrp/internal/convert/converter_gotest.go new file mode 100644 index 00000000..863da231 --- /dev/null +++ b/hrp/internal/convert/converter_gotest.go @@ -0,0 +1,60 @@ +package convert + +import ( + _ "embed" + "os" + + "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/v4/hrp" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" +) + +func convert2GoTestScripts(paths ...string) error { + log.Warn().Msg("convert to gotest scripts is not supported yet") + os.Exit(1) + + // TODO + var testCasePaths []hrp.ITestCase + for _, path := range paths { + testCasePath := hrp.TestCasePath(path) + testCasePaths = append(testCasePaths, &testCasePath) + } + + testCases, err := hrp.LoadTestCases(testCasePaths...) + if err != nil { + log.Error().Err(err).Msg("failed to load testcases") + return err + } + + var pytestPaths []string + for _, testCase := range testCases { + tc := testCase.ToTCase() + converter := TCaseConverter{ + TCase: tc, + } + pytestPath, err := converter.ToPyTest() + if err != nil { + log.Error().Err(err). + Str("originPath", tc.Config.Path). + Msg("convert to pytest failed") + continue + } + log.Info(). + Str("pytestPath", pytestPath). + Str("originPath", tc.Config.Path). + Msg("convert to pytest success") + pytestPaths = append(pytestPaths, pytestPath) + } + + // format pytest scripts with black + python3, err := builtin.EnsurePython3Venv("black") + if err != nil { + return err + } + args := append([]string{"-m", "black"}, pytestPaths...) + return builtin.ExecCommand(python3, args...) +} + +//go:embed testcase.tmpl +var testcaseTemplate string diff --git a/hrp/internal/har2case/har.go b/hrp/internal/convert/converter_har.go similarity index 62% rename from hrp/internal/har2case/har.go rename to hrp/internal/convert/converter_har.go index 6b98839a..6ee9c156 100644 --- a/hrp/internal/har2case/har.go +++ b/hrp/internal/convert/converter_har.go @@ -1,6 +1,22 @@ -package har2case +package convert -import "time" +import ( + "encoding/base64" + "fmt" + "net/url" + "sort" + "strings" + "time" + + "github.com/pkg/errors" + "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/json" +) + +// ==================== model definition starts here ==================== /* HTTP Archive (HAR) format @@ -8,8 +24,8 @@ https://w3c.github.io/web-performance/specs/HAR/Overview.html this file is copied from https://github.com/mrichman/hargo/blob/master/types.go */ -// Har is a container type for deserialization -type Har struct { +// CaseHar is a container type for deserialization +type CaseHar struct { Log Log `json:"log"` } @@ -338,3 +354,333 @@ type TestResult struct { Method string `json:"method"` HarFile string `json:"harfile"` } + +// ==================== model definition ends here ==================== + +func NewConverterHAR(converter *TCaseConverter) *ConverterHAR { + return &ConverterHAR{ + converter: converter, + } +} + +type ConverterHAR struct { + converter *TCaseConverter +} + +func (c *ConverterHAR) Struct() *TCaseConverter { + return c.converter +} + +func (c *ConverterHAR) ToJSON() (string, error) { + tCase, err := c.makeTestCase() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(tCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterHAR) ToYAML() (string, error) { + tCase, err := c.makeTestCase() + if err != nil { + return "", err + } + yamlPath := c.converter.genOutputPath(suffixYAML) + err = builtin.Dump2YAML(tCase, yamlPath) + if err != nil { + return "", err + } + return yamlPath, nil +} + +func (c *ConverterHAR) ToGoTest() (string, error) { + //TODO implement me + return "", errors.New("convert from har to gotest scripts is not supported yet") +} + +func (c *ConverterHAR) ToPyTest() (string, error) { + return convertToPyTest(c) +} + +func (c *ConverterHAR) makeTestCase() (*hrp.TCase, error) { + teststeps, err := c.prepareTestSteps() + if err != nil { + return nil, err + } + + tCase := &hrp.TCase{ + Config: c.prepareConfig(), + TestSteps: teststeps, + } + err = tCase.MakeCompat() + if err != nil { + return nil, err + } + return tCase, nil +} + +func (c *ConverterHAR) load() (*CaseHar, error) { + har := c.converter.CaseHAR + if har == nil { + return nil, errors.New("empty har case occurs") + } + return har, nil +} + +func (c *ConverterHAR) prepareConfig() *hrp.TConfig { + return hrp.NewConfig("testcase description"). + SetVerifySSL(false) +} + +func (c *ConverterHAR) prepareTestSteps() ([]*hrp.TStep, error) { + har, err := c.load() + if err != nil { + return nil, err + } + + var steps []*hrp.TStep + for _, entry := range har.Log.Entries { + step, err := c.prepareTestStep(&entry) + if err != nil { + return nil, err + } + steps = append(steps, step) + } + + return steps, nil +} + +func (c *ConverterHAR) prepareTestStep(entry *Entry) (*hrp.TStep, error) { + log.Info(). + Str("method", entry.Request.Method). + Str("url", entry.Request.URL). + Msg("convert teststep") + + step := &stepFromHAR{ + TStep: hrp.TStep{ + Request: &hrp.Request{}, + Validators: make([]interface{}, 0), + }, + profile: c.converter.Profile, + } + if err := step.makeRequestMethod(entry); err != nil { + return nil, err + } + if err := step.makeRequestURL(entry); err != nil { + return nil, err + } + if err := step.makeRequestParams(entry); err != nil { + return nil, err + } + if err := step.makeRequestCookies(entry); err != nil { + return nil, err + } + if err := step.makeRequestHeaders(entry); err != nil { + return nil, err + } + if err := step.makeRequestBody(entry); err != nil { + return nil, err + } + if err := step.makeValidate(entry); err != nil { + return nil, err + } + return &step.TStep, nil +} + +type stepFromHAR struct { + hrp.TStep + profile *Profile +} + +func (s *stepFromHAR) makeRequestMethod(entry *Entry) error { + s.Request.Method = hrp.HTTPMethod(entry.Request.Method) + return nil +} + +func (s *stepFromHAR) makeRequestURL(entry *Entry) error { + u, err := url.Parse(entry.Request.URL) + if err != nil { + log.Error().Err(err).Msg("make request url failed") + return err + } + s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path) + return nil +} + +func (s *stepFromHAR) makeRequestParams(entry *Entry) error { + s.Request.Params = make(map[string]interface{}) + for _, param := range entry.Request.QueryString { + s.Request.Params[param.Name] = param.Value + } + return nil +} + +func (s *stepFromHAR) makeRequestCookies(entry *Entry) error { + // use cookies from har + s.Request.Cookies = make(map[string]string) + for _, cookie := range entry.Request.Cookies { + s.Request.Cookies[cookie.Name] = cookie.Value + } + + if s.profile == nil { + return nil + } + // override all cookies according to the profile + if s.profile.Override { + s.Request.Cookies = make(map[string]string) + } + // create or update the cookies according to the profile + for k, v := range s.profile.Cookies { + s.Request.Cookies[k] = v + } + return nil +} + +func (s *stepFromHAR) makeRequestHeaders(entry *Entry) error { + // use headers from har + s.Request.Headers = make(map[string]string) + for _, header := range entry.Request.Headers { + if strings.EqualFold(header.Name, "cookie") { + continue + } + s.Request.Headers[header.Name] = header.Value + } + + if s.profile == nil { + return nil + } + // override all headers according to the profile + if s.profile.Override { + s.Request.Headers = make(map[string]string) + } + // create or update the headers according to the profile + for k, v := range s.profile.Headers { + s.Request.Headers[k] = v + } + return nil +} + +func (s *stepFromHAR) makeRequestBody(entry *Entry) error { + mimeType := entry.Request.PostData.MimeType + if mimeType == "" { + // GET/HEAD/DELETE without body + return nil + } + + // POST/PUT with body + if strings.HasPrefix(mimeType, "application/json") { + // post json + var body interface{} + if entry.Request.PostData.Text == "" { + body = nil + } else { + err := json.Unmarshal([]byte(entry.Request.PostData.Text), &body) + if err != nil { + log.Error().Err(err).Msg("make request body failed") + return err + } + } + s.Request.Body = body + } else if strings.HasPrefix(mimeType, "application/x-www-form-urlencoded") { + // post form + paramsMap := make(map[string]string) + for _, param := range entry.Request.PostData.Params { + paramsMap[param.Name] = param.Value + } + s.Request.Body = paramsMap + } else if strings.HasPrefix(mimeType, "text/plain") { + // post raw data + s.Request.Body = entry.Request.PostData.Text + } else { + // TODO + log.Error().Msgf("makeRequestBody: Not implemented for mimeType %s", mimeType) + } + return nil +} + +func (s *stepFromHAR) makeValidate(entry *Entry) error { + // make validator for response status code + s.Validators = append(s.Validators, hrp.Validator{ + Check: "status_code", + Assert: "equals", + Expect: entry.Response.Status, + Message: "assert response status code", + }) + + // make validators for response headers + for _, header := range entry.Response.Headers { + // assert Content-Type + if strings.EqualFold(header.Name, "Content-Type") { + s.Validators = append(s.Validators, hrp.Validator{ + Check: "headers.\"Content-Type\"", + Assert: "equals", + Expect: header.Value, + Message: "assert response header Content-Type", + }) + } + } + + // make validators for response body + respBody := entry.Response.Content + if respBody.Text == "" { + // response body is empty + return nil + } + if strings.HasPrefix(respBody.MimeType, "application/json") { + var data []byte + var err error + // response body is json + if respBody.Encoding == "base64" { + // decode base64 text + data, err = base64.StdEncoding.DecodeString(respBody.Text) + if err != nil { + return errors.Wrap(err, "decode base64 error") + } + } else if respBody.Encoding == "" { + // no encoding + data = []byte(respBody.Text) + } else { + // other encoding type + return nil + } + // convert to json + var body interface{} + if err = json.Unmarshal(data, &body); err != nil { + return errors.Wrap(err, "json.Unmarshal body error") + } + jsonBody, ok := body.(map[string]interface{}) + if !ok { + return fmt.Errorf("response body is not json, not matched with MimeType") + } + + // response body is json + keys := make([]string, 0, len(jsonBody)) + for k := range jsonBody { + keys = append(keys, k) + } + // sort map keys to keep validators in stable order + sort.Strings(keys) + for _, key := range keys { + value := jsonBody[key] + switch v := value.(type) { + case map[string]interface{}: + continue + case []interface{}: + continue + default: + s.Validators = append(s.Validators, hrp.Validator{ + Check: fmt.Sprintf("body.%s", key), + Assert: "equals", + Expect: v, + Message: fmt.Sprintf("assert response body %s", key), + }) + } + } + } + + return nil +} diff --git a/hrp/internal/har2case/core_test.go b/hrp/internal/convert/converter_har_test.go similarity index 74% rename from hrp/internal/har2case/core_test.go rename to hrp/internal/convert/converter_har_test.go index 25779e12..af94c98c 100644 --- a/hrp/internal/har2case/core_test.go +++ b/hrp/internal/convert/converter_har_test.go @@ -1,21 +1,24 @@ -package har2case +package convert import ( "testing" "github.com/stretchr/testify/assert" - "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/v4/hrp" ) var ( - harPath = "../../../examples/data/har/demo.har" - harPath2 = "../../../examples/data/har/postman-echo.har" - profilePath = "../../../examples/data/har/profile.yml" + harPath = "../../../examples/data/har/demo.har" + harPath2 = "../../../examples/data/har/postman-echo.har" + harProfileOverridePath = "../../../examples/data/har/profile_override.yml" ) -func TestGenJSON(t *testing.T) { - jsonPath, err := NewHAR(harPath).GenJSON() +var converterHAR = NewConverterHAR(NewTCaseConverter(harPath)) +var converterHAR2 = NewConverterHAR(NewTCaseConverter(harPath2)) + +func TestHAR2JSON(t *testing.T) { + jsonPath, err := converterHAR.ToJSON() if !assert.NoError(t, err) { t.Fatal() } @@ -24,8 +27,8 @@ func TestGenJSON(t *testing.T) { } } -func TestGenYAML(t *testing.T) { - yamlPath, err := NewHAR(harPath2).GenYAML() +func TestHAR2YAML(t *testing.T) { + yamlPath, err := converterHAR2.ToYAML() if !assert.NoError(t, err) { t.Fatal() } @@ -35,8 +38,7 @@ func TestGenYAML(t *testing.T) { } func TestLoadHAR(t *testing.T) { - har := NewHAR(harPath) - h, err := har.load() + h, err := converterHAR.load() if !assert.NoError(t, err) { t.Fatal() } @@ -49,28 +51,28 @@ func TestLoadHAR(t *testing.T) { } func TestLoadHARWithProfile(t *testing.T) { - har := NewHAR(harPath) - har.SetProfile(profilePath) - _, err := har.load() + tCaseConverter := NewTCaseConverter(harPath) + tCaseConverter.SetProfile(harProfileOverridePath) + h := NewConverterHAR(tCaseConverter) + _, err := h.load() if !assert.NoError(t, err) { t.Fatal() } if !assert.Equal(t, - map[string]interface{}{"Content-Type": "application/x-www-form-urlencoded"}, - har.profile["headers"]) { + map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + h.converter.Profile.Headers) { t.Fatal() } if !assert.Equal(t, - map[string]interface{}{"UserName": "debugtalk"}, - har.profile["cookies"]) { + map[string]string{"UserName": "debugtalk"}, + h.converter.Profile.Cookies) { t.Fatal() } } -func TestMakeTestCase(t *testing.T) { - har := NewHAR(harPath) - tCase, err := har.makeTestCase() +func TestMakeTestCaseFromHAR(t *testing.T) { + tCase, err := converterHAR.makeTestCase() if !assert.NoError(t, err) { t.Fatal() } @@ -116,7 +118,7 @@ func TestMakeTestCase(t *testing.T) { if !assert.Equal(t, map[string]interface{}{"foo1": "HDnY8", "foo2": 12.3}, tCase.TestSteps[1].Request.Body) { t.Fatal() } - if !assert.Equal(t, "foo1=HDnY8&foo2=12.3", tCase.TestSteps[2].Request.Body) { + if !assert.Equal(t, map[string]string{"foo1": "HDnY8", "foo2": "12.3"}, tCase.TestSteps[2].Request.Body) { t.Fatal() } @@ -135,21 +137,13 @@ func TestMakeTestCase(t *testing.T) { } } -func TestGetFilenameWithoutExtension(t *testing.T) { - filename := getFilenameWithoutExtension(harPath2) - if !assert.Equal(t, "postman-echo", filename) { - t.Fatal() - } -} - func TestMakeRequestURL(t *testing.T) { - har := NewHAR("") entry := &Entry{ Request: Request{ URL: "http://127.0.0.1:8080/api/login", }, } - step, err := har.prepareTestStep(entry) + step, err := converterHAR.prepareTestStep(entry) if !assert.NoError(t, err) { t.Fatal() } @@ -160,7 +154,6 @@ func TestMakeRequestURL(t *testing.T) { } func TestMakeRequestHeaders(t *testing.T) { - har := NewHAR("") entry := &Entry{ Request: Request{ Method: "POST", @@ -169,7 +162,7 @@ func TestMakeRequestHeaders(t *testing.T) { }, }, } - step, err := har.prepareTestStep(entry) + step, err := converterHAR.prepareTestStep(entry) if !assert.NoError(t, err) { t.Fatal() } @@ -181,9 +174,10 @@ func TestMakeRequestHeaders(t *testing.T) { } } -func TestMakeRequestHeadersWithProfile(t *testing.T) { - har := NewHAR("") - har.SetProfile(profilePath) +func TestMakeRequestHeadersWithProfileOverride(t *testing.T) { + tCaseConverter := NewTCaseConverter(harPath) + tCaseConverter.SetProfile(harProfileOverridePath) + h := NewConverterHAR(tCaseConverter) entry := &Entry{ Request: Request{ Method: "POST", @@ -192,7 +186,7 @@ func TestMakeRequestHeadersWithProfile(t *testing.T) { }, }, } - step, err := har.prepareTestStep(entry) + step, err := h.prepareTestStep(entry) if !assert.NoError(t, err) { t.Fatal() } @@ -205,7 +199,6 @@ func TestMakeRequestHeadersWithProfile(t *testing.T) { } func TestMakeRequestCookies(t *testing.T) { - har := NewHAR("") entry := &Entry{ Request: Request{ Method: "POST", @@ -215,7 +208,7 @@ func TestMakeRequestCookies(t *testing.T) { }, }, } - step, err := har.prepareTestStep(entry) + step, err := converterHAR.prepareTestStep(entry) if !assert.NoError(t, err) { t.Fatal() } @@ -228,9 +221,10 @@ func TestMakeRequestCookies(t *testing.T) { } } -func TestMakeRequestCookiesWithProfile(t *testing.T) { - har := NewHAR("") - har.SetProfile(profilePath) +func TestMakeRequestCookiesWithProfileOverride(t *testing.T) { + tCaseConverter := NewTCaseConverter(harPath) + tCaseConverter.SetProfile(harProfileOverridePath) + h := NewConverterHAR(tCaseConverter) entry := &Entry{ Request: Request{ Method: "POST", @@ -240,7 +234,7 @@ func TestMakeRequestCookiesWithProfile(t *testing.T) { }, }, } - step, err := har.prepareTestStep(entry) + step, err := h.prepareTestStep(entry) if !assert.NoError(t, err) { t.Fatal() } @@ -253,7 +247,6 @@ func TestMakeRequestCookiesWithProfile(t *testing.T) { } func TestMakeRequestDataParams(t *testing.T) { - har := NewHAR("") entry := &Entry{ Request: Request{ Method: "POST", @@ -266,18 +259,17 @@ func TestMakeRequestDataParams(t *testing.T) { }, }, } - step, err := har.prepareTestStep(entry) + step, err := converterHAR.prepareTestStep(entry) if !assert.NoError(t, err) { t.Fatal() } - if !assert.Equal(t, "a=1&b=2", step.Request.Body) { + if !assert.Equal(t, map[string]string{"a": "1", "b": "2"}, step.Request.Body) { t.Fatal() } } func TestMakeRequestDataJSON(t *testing.T) { - har := NewHAR("") entry := &Entry{ Request: Request{ Method: "POST", @@ -287,7 +279,7 @@ func TestMakeRequestDataJSON(t *testing.T) { }, }, } - step, err := har.prepareTestStep(entry) + step, err := converterHAR.prepareTestStep(entry) if !assert.NoError(t, err) { t.Fatal() } @@ -298,7 +290,6 @@ func TestMakeRequestDataJSON(t *testing.T) { } func TestMakeRequestDataTextEmpty(t *testing.T) { - har := NewHAR("") entry := &Entry{ Request: Request{ Method: "POST", @@ -308,7 +299,7 @@ func TestMakeRequestDataTextEmpty(t *testing.T) { }, }, } - step, err := har.prepareTestStep(entry) + step, err := converterHAR.prepareTestStep(entry) if !assert.NoError(t, err) { t.Fatal() } @@ -319,7 +310,6 @@ func TestMakeRequestDataTextEmpty(t *testing.T) { } func TestMakeValidate(t *testing.T) { - har := NewHAR("") entry := &Entry{ Response: Response{ Status: 200, @@ -335,7 +325,7 @@ func TestMakeValidate(t *testing.T) { }, }, } - step, err := har.prepareTestStep(entry) + step, err := converterHAR.prepareTestStep(entry) if !assert.NoError(t, err) { t.Fatal() } @@ -348,7 +338,8 @@ func TestMakeValidate(t *testing.T) { Check: "status_code", Expect: 200, Assert: "equals", - Message: "assert response status code"}) { + Message: "assert response status code", + }) { t.Fatal() } @@ -361,7 +352,8 @@ func TestMakeValidate(t *testing.T) { Check: "headers.\"Content-Type\"", Expect: "application/json; charset=utf-8", Assert: "equals", - Message: "assert response header Content-Type"}) { + Message: "assert response header Content-Type", + }) { t.Fatal() } @@ -374,7 +366,8 @@ func TestMakeValidate(t *testing.T) { Check: "body.Code", Expect: float64(200), // TODO Assert: "equals", - Message: "assert response body Code"}) { + Message: "assert response body Code", + }) { t.Fatal() } } diff --git a/hrp/internal/convert/converter_json.go b/hrp/internal/convert/converter_json.go new file mode 100644 index 00000000..fc380142 --- /dev/null +++ b/hrp/internal/convert/converter_json.go @@ -0,0 +1,86 @@ +package convert + +import ( + "fmt" + + "github.com/pkg/errors" + + "github.com/httprunner/httprunner/v4/hrp" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/version" +) + +func NewConverterJSON(converter *TCaseConverter) *ConverterJSON { + return &ConverterJSON{ + converter: converter, + } +} + +type ConverterJSON struct { + converter *TCaseConverter +} + +func (c *ConverterJSON) Struct() *TCaseConverter { + return c.converter +} + +func (c *ConverterJSON) ToJSON() (string, error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(testCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterJSON) ToYAML() (string, error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + yamlPath := c.converter.genOutputPath(suffixYAML) + err = builtin.Dump2YAML(testCase, yamlPath) + if err != nil { + return "", err + } + return yamlPath, nil +} + +func (c *ConverterJSON) ToGoTest() (string, error) { + //TODO implement me + return "", errors.New("convert from json testcase to gotest scripts is not supported yet") +} + +func (c *ConverterJSON) ToPyTest() (string, error) { + return convertToPyTest(c) +} + +func (c *ConverterJSON) MakePyTestScript() (string, error) { + httprunner := fmt.Sprintf("httprunner>=%s", version.HttpRunnerMinVersion) + python3, err := builtin.EnsurePython3Venv(httprunner) + if err != nil { + return "", err + } + args := append([]string{"-m", "httprunner", "make"}, c.converter.InputPath) + err = builtin.ExecCommand(python3, args...) + if err != nil { + return "", err + } + return c.converter.genOutputPath(suffixPyTest), nil +} + +func (c *ConverterJSON) makeTestCase() (*hrp.TCase, error) { + tCase, err := makeTestCaseFromJSONYAML(c) + if err != nil { + return nil, err + } + err = tCase.MakeCompat() + if err != nil { + return nil, err + } + return tCase, nil +} diff --git a/hrp/internal/convert/converter_postman.go b/hrp/internal/convert/converter_postman.go new file mode 100644 index 00000000..bfa9a19e --- /dev/null +++ b/hrp/internal/convert/converter_postman.go @@ -0,0 +1,488 @@ +package convert + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/pkg/errors" + "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/json" +) + +// ==================== model definition starts here ==================== + +/* +Postman Collection format reference: +https://schema.postman.com/json/collection/v2.0.0/collection.json +https://schema.postman.com/json/collection/v2.1.0/collection.json +*/ + +// CasePostman represents the postman exported file +type CasePostman struct { + Info TInfo `json:"info"` + Items []TItem `json:"item"` +} + +// TInfo gives information about the collection +type TInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Schema string `json:"schema"` +} + +// TItem contains the detail information of request and expected responses +// item could be defined recursively +type TItem struct { + Items []TItem `json:"item"` + Name string `json:"name"` + Request TRequest `json:"request"` + Responses []TResponse `json:"response"` +} + +type TRequest struct { + Method string `json:"method"` + Headers []TField `json:"header"` + Body TBody `json:"body"` + URL TUrl `json:"url"` + Description string `json:"description"` +} + +type TResponse struct { + Name string `json:"name"` + OriginalRequest TRequest `json:"originalRequest"` + Status string `json:"status"` + Code int `json:"code"` + Headers []TField `json:"headers"` + Body string `json:"body"` +} + +type TUrl struct { + Raw string `json:"raw"` + Protocol string `json:"protocol"` + Path []string `json:"path"` + Description string `json:"description"` + Query []TField `json:"query"` + Variable []TField `json:"variable"` +} + +type TField struct { + Key string `json:"key"` + Value string `json:"value"` + Src string `json:"src"` + Description string `json:"description"` + Type string `json:"type"` + Disabled bool `json:"disabled"` + Enable bool `json:"enable"` +} + +type TBody struct { + Mode string `json:"mode"` + FormData []TField `json:"formdata"` + URLEncoded []TField `json:"urlencoded"` + Raw string `json:"raw"` + Disabled bool `json:"disabled"` + Options interface{} `json:"options"` +} + +// ==================== model definition ends here ==================== + +const ( + enumBodyRaw = "raw" + enumBodyUrlEncoded = "urlencoded" + enumBodyFormData = "formdata" + enumBodyFile = "file" + enumBodyGraphQL = "graphql" +) + +const ( + enumFieldTypeText = "text" + enumFieldTypeFile = "file" +) + +var contentTypeMap = map[string]string{ + "text": "text/plain", + "javascript": "application/javascript", + "json": "application/json", + "html": "text/html", + "xml": "application/xml", +} + +func NewConverterPostman(converter *TCaseConverter) *ConverterPostman { + return &ConverterPostman{ + converter: converter, + } +} + +type ConverterPostman struct { + converter *TCaseConverter +} + +func (c *ConverterPostman) Struct() *TCaseConverter { + return c.converter +} + +func (c *ConverterPostman) ToJSON() (string, error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(testCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterPostman) ToYAML() (string, error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + yamlPath := c.converter.genOutputPath(suffixYAML) + err = builtin.Dump2YAML(testCase, yamlPath) + if err != nil { + return "", err + } + return yamlPath, nil +} + +func (c *ConverterPostman) ToGoTest() (string, error) { + //TODO implement me + return "", errors.New("convert from postman to gotest scripts is not supported yet") +} + +func (c *ConverterPostman) ToPyTest() (string, error) { + return convertToPyTest(c) +} + +func (c *ConverterPostman) makeTestCase() (*hrp.TCase, error) { + casePostman, err := c.load() + if err != nil { + return nil, err + } + teststeps, err := c.prepareTestSteps(casePostman) + if err != nil { + return nil, err + } + tCase := &hrp.TCase{ + Config: c.prepareConfig(casePostman), + TestSteps: teststeps, + } + err = tCase.MakeCompat() + if err != nil { + return nil, err + } + return tCase, nil +} + +func (c *ConverterPostman) load() (*CasePostman, error) { + casePostman := c.converter.CasePostman + if casePostman == nil { + return nil, errors.New("empty postman case occurs") + } + return casePostman, nil +} + +func (c *ConverterPostman) prepareConfig(casePostman *CasePostman) *hrp.TConfig { + return hrp.NewConfig(casePostman.Info.Name). + SetVerifySSL(false) +} + +func (c *ConverterPostman) prepareTestSteps(casePostman *CasePostman) ([]*hrp.TStep, error) { + // recursively convert collection items into a list + var itemList []TItem + for _, item := range casePostman.Items { + extractItemList(item, &itemList) + } + + var steps []*hrp.TStep + for _, item := range itemList { + step, err := c.prepareTestStep(&item, steps) + if err != nil { + return nil, err + } + steps = append(steps, step) + } + return steps, nil +} + +func extractItemList(item TItem, itemList *[]TItem) { + // current item contains no other items and request is not empty + if len(item.Items) == 0 { + if !reflect.DeepEqual(item.Request, TRequest{}) { + *itemList = append(*itemList, item) + } + return + } + + // look up all items inside + for _, i := range item.Items { + // append item name + i.Name = fmt.Sprintf("%s - %s", item.Name, i.Name) + extractItemList(i, itemList) + } +} + +func (c *ConverterPostman) prepareTestStep(item *TItem, steps []*hrp.TStep) (*hrp.TStep, error) { + log.Info(). + Str("method", item.Request.Method). + Str("url", item.Request.URL.Raw). + Msg("convert teststep") + + step := &stepFromPostman{ + TStep: hrp.TStep{ + Request: &hrp.Request{}, + Validators: make([]interface{}, 0), + }, + profile: c.converter.Profile, + } + if err := step.makeRequestName(item); err != nil { + return nil, err + } + if err := step.makeRequestMethod(item); err != nil { + return nil, err + } + if err := step.makeRequestURL(item); err != nil { + return nil, err + } + if err := step.makeRequestParams(item); err != nil { + return nil, err + } + if err := step.makeRequestHeaders(item); err != nil { + return nil, err + } + if err := step.makeRequestCookies(item); err != nil { + return nil, err + } + if err := step.makeRequestBody(item, steps); err != nil { + return nil, err + } + return &step.TStep, nil +} + +type stepFromPostman struct { + hrp.TStep + profile *Profile +} + +// makeRequestName indicates the step name the same as item name +func (s *stepFromPostman) makeRequestName(item *TItem) error { + s.Name = item.Name + return nil +} + +func (s *stepFromPostman) makeRequestMethod(item *TItem) error { + s.Request.Method = hrp.HTTPMethod(item.Request.Method) + return nil +} + +func (s *stepFromPostman) makeRequestURL(item *TItem) error { + rawUrl := item.Request.URL.Raw + // parse path variables like ":path" in https://postman-echo.com/:path?k1=v1&k2=v2 + for _, field := range item.Request.URL.Variable { + pathVar := ":" + field.Key + rawUrl = strings.Replace(rawUrl, pathVar, field.Value, -1) + } + u, err := url.Parse(rawUrl) + if err != nil { + return errors.Wrap(err, "parse URL error") + } + s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path) + return nil +} + +func (s *stepFromPostman) makeRequestParams(item *TItem) error { + s.Request.Params = make(map[string]interface{}) + for _, field := range item.Request.URL.Query { + if field.Disabled { + continue + } + s.Request.Params[field.Key] = field.Value + } + return nil +} + +func (s *stepFromPostman) makeRequestHeaders(item *TItem) error { + // headers defined in postman collection + s.Request.Headers = make(map[string]string) + for _, field := range item.Request.Headers { + if field.Disabled || strings.EqualFold(field.Key, "cookie") { + continue + } + s.Request.Headers[field.Key] = field.Value + } + + if s.profile == nil { + return nil + } + // override all headers according to the profile + if s.profile.Override { + s.Request.Headers = make(map[string]string) + } + // create or update the headers according to the profile + for k, v := range s.profile.Headers { + s.Request.Headers[k] = v + } + return nil +} + +func (s *stepFromPostman) makeRequestCookies(item *TItem) error { + // cookies defined in postman collection + s.Request.Cookies = make(map[string]string) + for _, field := range item.Request.Headers { + if field.Disabled || !strings.EqualFold(field.Key, "cookie") { + continue + } + s.parseRequestCookiesMap(field.Value) + } + + if s.profile == nil { + return nil + } + // override all cookies according to the profile + if s.profile.Override { + s.Request.Cookies = make(map[string]string) + } + // create or update the cookies according to the profile + for k, v := range s.profile.Cookies { + s.Request.Cookies[k] = v + } + return nil +} + +func (s *stepFromPostman) parseRequestCookiesMap(cookies string) { + for _, cookie := range strings.Split(cookies, ";") { + cookie = strings.TrimSpace(cookie) + index := strings.Index(cookie, "=") + if index == -1 { + log.Warn().Str("cookie", cookie).Msg("cookie format invalid") + continue + } + s.Request.Cookies[cookie[:index]] = cookie[index+1:] + } +} + +func (s *stepFromPostman) makeRequestBody(item *TItem, steps []*hrp.TStep) error { + mode := item.Request.Body.Mode + if mode == "" { + return nil + } + switch mode { + case enumBodyRaw: + return s.makeRequestBodyRaw(item) + case enumBodyFormData: + return s.makeRequestBodyFormData(item, steps) + case enumBodyUrlEncoded: + return s.makeRequestBodyUrlEncoded(item) + case enumBodyFile, enumBodyGraphQL: + return errors.Errorf("unsupported body type: %v", mode) + } + return nil +} + +func (s *stepFromPostman) makeRequestBodyRaw(item *TItem) (err error) { + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("make request body (raw) failed: %v", p) + } + }() + + // extract language type, default languageType: text + languageType := "text" + iOptions := item.Request.Body.Options + if iOptions != nil { + iLanguage := iOptions.(map[string]interface{})["raw"] + if iLanguage != nil { + languageType = iLanguage.(map[string]interface{})["language"].(string) + } + } + + // make request body and indicate Content-Type + rawBody := item.Request.Body.Raw + if languageType == "json" { + var iBody interface{} + err = json.Unmarshal([]byte(rawBody), &iBody) + if err != nil { + return errors.Wrap(err, "make request body (raw -> json) failed") + } + s.Request.Body = iBody + } else { + s.Request.Body = rawBody + } + s.Request.Headers["Content-Type"] = contentTypeMap[languageType] + return +} + +func (s *stepFromPostman) makeRequestBodyFormData(item *TItem, steps []*hrp.TStep) (err error) { + defer func() { + if err != nil { + err = errors.Wrap(err, "make request body form-data failed") + } + }() + payload := &bytes.Buffer{} + writer := multipart.NewWriter(payload) + for _, field := range item.Request.Body.FormData { + if field.Disabled { + continue + } + // form data could be text or file + if field.Type == enumFieldTypeText { + err = writer.WriteField(field.Key, field.Value) + if err != nil { + return + } + } else if field.Type == enumFieldTypeFile { + err = writeFormDataFile(writer, &field) + if err != nil { + return + } + } + } + err = writer.Close() + s.Request.Body = payload.String() + s.Request.Headers["Content-Type"] = writer.FormDataContentType() + return +} + +func writeFormDataFile(writer *multipart.Writer, field *TField) error { + file, err := os.Open(field.Src) + if err != nil { + return err + } + defer file.Close() + formFile, err := writer.CreateFormFile(field.Key, filepath.Base(field.Src)) + if err != nil { + return err + } + _, err = io.Copy(formFile, file) + return err +} + +func (s *stepFromPostman) makeRequestBodyUrlEncoded(item *TItem) error { + payloadMap := make(map[string]string) + for _, field := range item.Request.Body.URLEncoded { + if field.Disabled { + continue + } + payloadMap[field.Key] = field.Value + } + s.Request.Body = payloadMap + s.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded" + return nil +} + +// TODO makeValidate from example response +func (s *stepFromPostman) makeValidate(item *TItem) error { + return nil +} diff --git a/hrp/internal/convert/converter_postman_test.go b/hrp/internal/convert/converter_postman_test.go new file mode 100644 index 00000000..9e8ad126 --- /dev/null +++ b/hrp/internal/convert/converter_postman_test.go @@ -0,0 +1,159 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + collectionPath = "../../../examples/data/postman/postman_collection.json" + collectionProfileOverridePath = "../../../examples/data/postman/profile_override.yml" + collectionProfilePath = "../../../examples/data/postman/profile.yml" +) + +var converterPostman = NewConverterPostman(NewTCaseConverter(collectionPath)) + +func TestPostman2JSON(t *testing.T) { + jsonPath, err := converterPostman.ToJSON() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.NotEmpty(t, jsonPath) { + t.Fatal() + } +} + +func TestPostman2YAML(t *testing.T) { + yamlPath, err := converterPostman.ToYAML() + if !assert.NoError(t, err) { + t.Fatal() + } + if !assert.NotEmpty(t, yamlPath) { + t.Fatal() + } +} + +func TestLoadCollection(t *testing.T) { + casePostman, err := converterPostman.load() + if !assert.NoError(t, err) { + t.Fatal(err) + } + if !assert.Equal(t, "postman collection demo", casePostman.Info.Name) { + t.Fatal() + } +} + +func TestMakeTestCaseFromCollection(t *testing.T) { + tCase, err := converterPostman.makeTestCase() + if !assert.NoError(t, err) { + t.Fatal() + } + // check name + if !assert.Equal(t, "postman collection demo", tCase.Config.Name) { + t.Fatal() + } + // check method + if !assert.EqualValues(t, "GET", tCase.TestSteps[0].Request.Method) { + t.Fatal() + } + if !assert.EqualValues(t, "POST", tCase.TestSteps[1].Request.Method) { + t.Fatal() + } + // check url + if !assert.Equal(t, "https://postman-echo.com/get", tCase.TestSteps[0].Request.URL) { + t.Fatal() + } + if !assert.Equal(t, "https://postman-echo.com/post", tCase.TestSteps[1].Request.URL) { + t.Fatal() + } + // check params + if !assert.Equal(t, "v1", tCase.TestSteps[0].Request.Params["k1"]) { + t.Fatal() + } + // check cookies (pass, postman collection doesn't contains cookies) + // check headers + if !assert.Contains(t, tCase.TestSteps[1].Request.Headers["Content-Type"], "multipart/form-data") { + t.Fatal() + } + if !assert.Equal(t, "application/x-www-form-urlencoded", tCase.TestSteps[2].Request.Headers["Content-Type"]) { + t.Fatal() + } + if !assert.Equal(t, "application/json", tCase.TestSteps[3].Request.Headers["Content-Type"]) { + t.Fatal() + } + if !assert.Equal(t, "text/plain", tCase.TestSteps[4].Request.Headers["Content-Type"]) { + t.Fatal() + } + if !assert.Equal(t, "HttpRunner", tCase.TestSteps[5].Request.Headers["User-Agent"]) { + t.Fatal() + } + // check body + if !assert.Equal(t, nil, tCase.TestSteps[0].Request.Body) { + t.Fatal() + } + if !assert.NotEmpty(t, tCase.TestSteps[1].Request.Body) { + t.Fatal() + } + if !assert.Equal(t, map[string]string{"k1": "v1", "k2": "v2"}, tCase.TestSteps[2].Request.Body) { + t.Fatal() + } + if !assert.Equal(t, map[string]interface{}{"k1": "v1", "k2": "v2"}, tCase.TestSteps[3].Request.Body) { + t.Fatal() + } + if !assert.Equal(t, "have a nice day", tCase.TestSteps[4].Request.Body) { + t.Fatal() + } + if !assert.Equal(t, nil, tCase.TestSteps[5].Request.Body) { + t.Fatal() + } +} + +func TestMakeTestCaseWithProfileOverride(t *testing.T) { + tCaseConverter := NewTCaseConverter(collectionPath) + tCaseConverter.SetProfile(collectionProfileOverridePath) + c := NewConverterPostman(tCaseConverter) + tCase, err := c.makeTestCase() + if !assert.NoError(t, err) { + t.Fatal() + } + for _, step := range tCase.TestSteps { + if step.Request.Method == "GET" && !assert.Len(t, step.Request.Headers, 1) { + t.Fatal() + } + if step.Request.Method == "POST" && !assert.Len(t, step.Request.Headers, 2) { + t.Fatal() + } + if !assert.Equal(t, "all original headers will be overridden", step.Request.Headers["Header1"]) { + t.Fatal() + } + if !assert.Len(t, step.Request.Cookies, 1) { + t.Fatal() + } + if !assert.Equal(t, "all original cookies will be overridden", step.Request.Cookies["Cookie1"]) { + t.Fatal() + } + } +} + +func TestMakeTestCaseWithProfile(t *testing.T) { + tCaseConverter := NewTCaseConverter(collectionPath) + tCaseConverter.SetProfile(collectionProfilePath) + c := NewConverterPostman(tCaseConverter) + tCase, err := c.makeTestCase() + if !assert.NoError(t, err) { + t.Fatal() + } + // create cookies Cookie1 indicated in profile + if !assert.Equal(t, "this cookie will be created or updated", tCase.TestSteps[0].Request.Cookies["Cookie1"]) { + t.Fatal() + } + // update header User-Agent indicated in profile + if !assert.Equal(t, "this header will be created or updated", tCase.TestSteps[5].Request.Headers["User-Agent"]) { + t.Fatal() + } + // pass header Connection which is not indicated in profile + if !assert.Equal(t, "close", tCase.TestSteps[5].Request.Headers["Connection"]) { + t.Fatal() + } +} diff --git a/hrp/internal/convert/converter_pytest.go b/hrp/internal/convert/converter_pytest.go new file mode 100644 index 00000000..8c094900 --- /dev/null +++ b/hrp/internal/convert/converter_pytest.go @@ -0,0 +1,19 @@ +package convert + +import ( + "fmt" + + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/version" +) + +func convert2PyTestScripts(paths ...string) error { + httprunner := fmt.Sprintf("httprunner>=%s", version.HttpRunnerMinVersion) + python3, err := builtin.EnsurePython3Venv(httprunner) + if err != nil { + return err + } + + args := append([]string{"-m", "httprunner", "make"}, paths...) + return builtin.ExecCommand(python3, args...) +} diff --git a/hrp/internal/convert/converter_yaml.go b/hrp/internal/convert/converter_yaml.go new file mode 100644 index 00000000..2ad783b1 --- /dev/null +++ b/hrp/internal/convert/converter_yaml.go @@ -0,0 +1,69 @@ +package convert + +import ( + "github.com/pkg/errors" + + "github.com/httprunner/httprunner/v4/hrp" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" +) + +func NewConverterYAML(converter *TCaseConverter) *ConverterYAML { + return &ConverterYAML{ + converter: converter, + } +} + +type ConverterYAML struct { + converter *TCaseConverter +} + +func (c *ConverterYAML) Struct() *TCaseConverter { + return c.converter +} + +func (c *ConverterYAML) ToJSON() (string, error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + jsonPath := c.converter.genOutputPath(suffixJSON) + err = builtin.Dump2JSON(testCase, jsonPath) + if err != nil { + return "", err + } + return jsonPath, nil +} + +func (c *ConverterYAML) ToYAML() (string, error) { + testCase, err := c.makeTestCase() + if err != nil { + return "", err + } + yamlPath := c.converter.genOutputPath(suffixYAML) + err = builtin.Dump2YAML(testCase, yamlPath) + if err != nil { + return "", err + } + return yamlPath, nil +} + +func (c *ConverterYAML) ToGoTest() (string, error) { + //TODO implement me + return "", errors.New("convert from yaml testcase to gotest scripts is not supported yet") +} + +func (c *ConverterYAML) ToPyTest() (string, error) { + return convertToPyTest(c) +} + +func (c *ConverterYAML) makeTestCase() (*hrp.TCase, error) { + tCase, err := makeTestCaseFromJSONYAML(c) + if err != nil { + return nil, err + } + err = tCase.MakeCompat() + if err != nil { + return nil, err + } + return tCase, nil +} diff --git a/hrp/internal/convert/main.go b/hrp/internal/convert/main.go deleted file mode 100644 index c871c55e..00000000 --- a/hrp/internal/convert/main.go +++ /dev/null @@ -1,120 +0,0 @@ -package convert - -import ( - _ "embed" - "fmt" - "os" - - "github.com/rs/zerolog/log" - - "github.com/httprunner/httprunner/hrp" - "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/sdk" - "github.com/httprunner/httprunner/hrp/internal/version" -) - -func Convert2TestScripts(destType string, paths ...string) error { - // report event - sdk.SendEvent(sdk.EventTracking{ - Category: "ConvertTests", - Action: fmt.Sprintf("hrp convert --%s", destType), - }) - - if destType == "gotest" { - return convert2GoTestScripts(paths...) - } else { - // default to pytest - return convert2PyTestScripts(paths...) - } -} - -func convert2PyTestScripts(paths ...string) error { - httprunner := fmt.Sprintf("httprunner>=%s", version.HttpRunnerMinVersion) - python3, err := builtin.EnsurePython3Venv(httprunner) - if err != nil { - return err - } - - args := append([]string{"-m", "httprunner", "make"}, paths...) - return builtin.ExecCommand(python3, args...) -} - -func convert2GoTestScripts(paths ...string) error { - log.Warn().Msg("convert to gotest scripts is not supported yet") - os.Exit(1) - - // TODO - var testCasePaths []hrp.ITestCase - for _, path := range paths { - testCasePath := hrp.TestCasePath(path) - testCasePaths = append(testCasePaths, &testCasePath) - } - - testCases, err := hrp.LoadTestCases(testCasePaths...) - if err != nil { - log.Error().Err(err).Msg("failed to load testcases") - return err - } - - var pytestPaths []string - for _, testCase := range testCases { - tc := testCase.ToTCase() - converter := CaseConverter{ - TCase: tc, - } - pytestPath, err := converter.ToPyTest() - if err != nil { - log.Error().Err(err). - Str("originPath", tc.Config.Path). - Msg("convert to pytest failed") - continue - } - log.Info(). - Str("pytestPath", pytestPath). - Str("originPath", tc.Config.Path). - Msg("convert to pytest success") - pytestPaths = append(pytestPaths, pytestPath) - } - - // format pytest scripts with black - python3, err := builtin.EnsurePython3Venv("black") - if err != nil { - return err - } - args := append([]string{"-m", "black"}, pytestPaths...) - return builtin.ExecCommand(python3, args...) -} - -//go:embed testcase.tmpl -var testcaseTemplate string - -type CaseConverter struct { - *hrp.TCase -} - -func (c *CaseConverter) ToPyTest() (string, error) { - script := convertConfig(c.TCase.Config) - println(script) - return script, nil -} - -func (c *CaseConverter) ToGoTest() (string, error) { - return "", nil -} - -func convertConfig(config *hrp.TConfig) string { - script := fmt.Sprintf("Config('%s')", config.Name) - - if config.Variables != nil { - script += fmt.Sprintf(".variables(**{%v})", config.Variables) - } - if config.BaseURL != "" { - script += fmt.Sprintf(".base_url('%s')", config.BaseURL) - } - if config.Export != nil { - script += fmt.Sprintf(".export(*%v)", config.Export) - } - script += fmt.Sprintf(".verify(%v)", config.Verify) - - return script -} diff --git a/hrp/internal/har2case/README.md b/hrp/internal/har2case/README.md deleted file mode 100644 index 08c0b4dc..00000000 --- a/hrp/internal/har2case/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# har2case - -Convert HAR(HTTP Archive) to YAML/JSON testcases for HttpRunner and HttpRunner+. - -## Install - -## Quick Start - -## Examples diff --git a/hrp/internal/har2case/core.go b/hrp/internal/har2case/core.go deleted file mode 100644 index d3772dd2..00000000 --- a/hrp/internal/har2case/core.go +++ /dev/null @@ -1,385 +0,0 @@ -package har2case - -import ( - "encoding/base64" - "fmt" - "net/url" - "path/filepath" - "sort" - "strings" - - "github.com/pkg/errors" - "github.com/rs/zerolog/log" - - "github.com/httprunner/httprunner/hrp" - "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/json" - "github.com/httprunner/httprunner/hrp/internal/sdk" -) - -const ( - suffixJSON = ".json" - suffixYAML = ".yaml" -) - -func NewHAR(path string) *har { - return &har{ - path: path, - } -} - -type har struct { - path string - filterStr string - excludeStr string - profile map[string]interface{} - outputDir string -} - -func (h *har) SetProfile(path string) { - log.Info().Str("path", path).Msg("set profile") - h.profile = make(map[string]interface{}) - err := builtin.LoadFile(path, h.profile) - if err != nil { - log.Warn().Str("path", path). - Msg("invalid profile format, ignore!") - } -} - -func (h *har) SetOutputDir(dir string) { - log.Info().Str("dir", dir).Msg("set output directory") - h.outputDir = dir -} - -func (h *har) GenJSON() (jsonPath string, err error) { - event := sdk.EventTracking{ - Category: "ConvertTests", - Action: "hrp har2case --to-json", - } - // report start event - go sdk.SendEvent(event) - // report running timing event - defer sdk.SendEvent(event.StartTiming("execution")) - - tCase, err := h.makeTestCase() - if err != nil { - return "", err - } - jsonPath = h.genOutputPath(suffixJSON) - err = builtin.Dump2JSON(tCase, jsonPath) - return -} - -func (h *har) GenYAML() (yamlPath string, err error) { - event := sdk.EventTracking{ - Category: "ConvertTests", - Action: "hrp har2case --to-yaml", - } - // report start event - go sdk.SendEvent(event) - // report running timing event - defer sdk.SendEvent(event.StartTiming("execution")) - - tCase, err := h.makeTestCase() - if err != nil { - return "", err - } - yamlPath = h.genOutputPath(suffixYAML) - err = builtin.Dump2YAML(tCase, yamlPath) - return -} - -func (h *har) makeTestCase() (*hrp.TCase, error) { - teststeps, err := h.prepareTestSteps() - if err != nil { - return nil, err - } - - tCase := &hrp.TCase{ - Config: h.prepareConfig(), - TestSteps: teststeps, - } - return tCase, nil -} - -func (h *har) load() (*Har, error) { - har := &Har{} - err := builtin.LoadFile(h.path, har) - if err != nil { - return nil, errors.Wrap(err, "load har failed") - } - return har, nil -} - -func (h *har) prepareConfig() *hrp.TConfig { - return hrp.NewConfig("testcase description"). - SetVerifySSL(false) -} - -func (h *har) prepareTestSteps() ([]*hrp.TStep, error) { - har, err := h.load() - if err != nil { - return nil, err - } - - var steps []*hrp.TStep - for _, entry := range har.Log.Entries { - step, err := h.prepareTestStep(&entry) - if err != nil { - return nil, err - } - steps = append(steps, step) - } - - return steps, nil -} - -func (h *har) prepareTestStep(entry *Entry) (*hrp.TStep, error) { - log.Info(). - Str("method", entry.Request.Method). - Str("url", entry.Request.URL). - Msg("convert teststep") - - step := &tStep{ - TStep: hrp.TStep{ - Request: &hrp.Request{}, - Validators: make([]interface{}, 0), - }, - profile: h.profile, - } - if err := step.makeRequestMethod(entry); err != nil { - return nil, err - } - if err := step.makeRequestURL(entry); err != nil { - return nil, err - } - if err := step.makeRequestParams(entry); err != nil { - return nil, err - } - if err := step.makeRequestCookies(entry); err != nil { - return nil, err - } - if err := step.makeRequestHeaders(entry); err != nil { - return nil, err - } - if err := step.makeRequestBody(entry); err != nil { - return nil, err - } - if err := step.makeValidate(entry); err != nil { - return nil, err - } - return &step.TStep, nil -} - -type tStep struct { - hrp.TStep - profile map[string]interface{} -} - -func (s *tStep) makeRequestMethod(entry *Entry) error { - s.Request.Method = hrp.HTTPMethod(entry.Request.Method) - return nil -} - -func (s *tStep) makeRequestURL(entry *Entry) error { - u, err := url.Parse(entry.Request.URL) - if err != nil { - log.Error().Err(err).Msg("make request url failed") - return err - } - s.Request.URL = fmt.Sprintf("%s://%s", u.Scheme, u.Host+u.Path) - return nil -} - -func (s *tStep) makeRequestParams(entry *Entry) error { - s.Request.Params = make(map[string]interface{}) - for _, param := range entry.Request.QueryString { - s.Request.Params[param.Name] = param.Value - } - return nil -} - -func (s *tStep) makeRequestCookies(entry *Entry) error { - s.Request.Cookies = make(map[string]string) - cookies, ok := s.profile["cookies"] - if ok { - // use cookies from profile - cookies, ok := cookies.(map[string]interface{}) - if ok { - for k, v := range cookies { - s.Request.Cookies[k] = fmt.Sprintf("%v", v) - } - return nil - } - log.Warn().Interface("cookies", cookies). - Msg("cookies from profile is not a map, ignore!") - } - - // use cookies from har - for _, cookie := range entry.Request.Cookies { - s.Request.Cookies[cookie.Name] = cookie.Value - } - return nil -} - -func (s *tStep) makeRequestHeaders(entry *Entry) error { - s.Request.Headers = make(map[string]string) - headers, ok := s.profile["headers"] - if ok { - // use headers from profile - cookies, ok := headers.(map[string]interface{}) - if ok { - for k, v := range cookies { - s.Request.Headers[k] = fmt.Sprintf("%v", v) - } - return nil - } - log.Warn().Interface("headers", headers). - Msg("headers from profile is not a map, ignore!") - } - - // use headers from har - for _, header := range entry.Request.Headers { - if strings.EqualFold(header.Name, "cookie") { - continue - } - s.Request.Headers[header.Name] = header.Value - } - return nil -} - -func (s *tStep) makeRequestBody(entry *Entry) error { - mimeType := entry.Request.PostData.MimeType - if mimeType == "" { - // GET/HEAD/DELETE without body - return nil - } - - // POST/PUT with body - if strings.HasPrefix(mimeType, "application/json") { - // post json - var body interface{} - if entry.Request.PostData.Text == "" { - body = nil - } else { - err := json.Unmarshal([]byte(entry.Request.PostData.Text), &body) - if err != nil { - log.Error().Err(err).Msg("make request body failed") - return err - } - } - s.Request.Body = body - } else if strings.HasPrefix(mimeType, "application/x-www-form-urlencoded") { - // post form - var paramsList []string - for _, param := range entry.Request.PostData.Params { - paramsList = append(paramsList, fmt.Sprintf("%s=%s", param.Name, param.Value)) - } - s.Request.Body = strings.Join(paramsList, "&") - } else if strings.HasPrefix(mimeType, "text/plain") { - // post raw data - s.Request.Body = entry.Request.PostData.Text - } else { - // TODO - log.Error().Msgf("makeRequestBody: Not implemented for mimeType %s", mimeType) - } - return nil -} - -func (s *tStep) makeValidate(entry *Entry) error { - // make validator for response status code - s.Validators = append(s.Validators, hrp.Validator{ - Check: "status_code", - Assert: "equals", - Expect: entry.Response.Status, - Message: "assert response status code", - }) - - // make validators for response headers - for _, header := range entry.Response.Headers { - // assert Content-Type - if strings.EqualFold(header.Name, "Content-Type") { - s.Validators = append(s.Validators, hrp.Validator{ - Check: "headers.\"Content-Type\"", - Assert: "equals", - Expect: header.Value, - Message: "assert response header Content-Type", - }) - } - } - - // make validators for response body - respBody := entry.Response.Content - if respBody.Text == "" { - // response body is empty - return nil - } - if strings.HasPrefix(respBody.MimeType, "application/json") { - var data []byte - var err error - // response body is json - if respBody.Encoding == "base64" { - // decode base64 text - data, err = base64.StdEncoding.DecodeString(respBody.Text) - if err != nil { - return errors.Wrap(err, "decode base64 error") - } - } else if respBody.Encoding == "" { - // no encoding - data = []byte(respBody.Text) - } else { - // other encoding type - return nil - } - // convert to json - var body interface{} - if err = json.Unmarshal(data, &body); err != nil { - return errors.Wrap(err, "json.Unmarshal body error") - } - jsonBody, ok := body.(map[string]interface{}) - if !ok { - return fmt.Errorf("response body is not json, not matched with MimeType") - } - - // response body is json - keys := make([]string, 0, len(jsonBody)) - for k := range jsonBody { - keys = append(keys, k) - } - // sort map keys to keep validators in stable order - sort.Strings(keys) - for _, key := range keys { - value := jsonBody[key] - switch v := value.(type) { - case map[string]interface{}: - continue - case []interface{}: - continue - default: - s.Validators = append(s.Validators, hrp.Validator{ - Check: fmt.Sprintf("body.%s", key), - Assert: "equals", - Expect: v, - Message: fmt.Sprintf("assert response body %s", key), - }) - } - } - } - - return nil -} - -func (h *har) genOutputPath(suffix string) string { - file := getFilenameWithoutExtension(h.path) + suffix - if h.outputDir != "" { - return filepath.Join(h.outputDir, file) - } else { - return filepath.Join(filepath.Dir(h.path), file) - } -} - -func getFilenameWithoutExtension(path string) string { - base := filepath.Base(path) - ext := filepath.Ext(base) - return base[0 : len(base)-len(ext)] -} diff --git a/hrp/internal/httpstat/main.go b/hrp/internal/httpstat/main.go new file mode 100644 index 00000000..49242193 --- /dev/null +++ b/hrp/internal/httpstat/main.go @@ -0,0 +1,268 @@ +// Package httpstat traces HTTP latency infomation (DNSLookup, TCP Connection and so on) on any golang HTTP request. +// It uses `httptrace` package. +// Inspired by https://github.com/tcnksm/go-httpstat and https://github.com/davecheney/httpstat +package httpstat + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "net/http/httptrace" + "strconv" + "strings" + "time" + + "github.com/fatih/color" + "github.com/rs/zerolog/log" +) + +const ( + httpsTemplate = "\n" + + ` DNS Lookup TCP Connection TLS Handshake Server Processing Content Transfer` + "\n" + + `[%s | %s | %s | %s | %s ]` + "\n" + + ` | | | | |` + "\n" + + ` namelookup:%s | | | |` + "\n" + + ` connect:%s | | |` + "\n" + + ` pretransfer:%s | |` + "\n" + + ` starttransfer:%s |` + "\n" + + ` total:%s` + "\n\n" + + httpTemplate = "\n" + + ` DNS Lookup TCP Connection Server Processing Content Transfer` + "\n" + + `[ %s | %s | %s | %s ]` + "\n" + + ` | | | |` + "\n" + + ` namelookup:%s | | |` + "\n" + + ` connect:%s | |` + "\n" + + ` starttransfer:%s |` + "\n" + + ` total:%s` + "\n\n" +) + +func fmta(d time.Duration) string { + return color.YellowString("%7dms", int(d.Milliseconds())) +} + +func fmtb(d time.Duration) string { + return color.RedString("%-9s", strconv.Itoa(int(d.Milliseconds()))+"ms") +} + +func grayscale(code color.Attribute) func(string, ...interface{}) string { + return color.New(code + 232).SprintfFunc() +} + +func colorize(s string) string { + v := strings.Split(s, "\n") + v[0] = grayscale(16)(v[0]) + return strings.Join(v, "\n") +} + +func printf(format string, a ...interface{}) (n int, err error) { + return fmt.Fprintf(color.Output, format, a...) +} + +// Stat stores httpstat info. +type Stat struct { + // The following are duration for each phase + // DNSLookup => TCPConnection => TLSHandshake => ServerProcessing => ContentTransfer + DNSLookup time.Duration + TCPConnection time.Duration + TLSHandshake time.Duration + ServerProcessing time.Duration + ContentTransfer time.Duration // from the first response byte to tansfer done. + + // The followings are timeline of request + NameLookup time.Duration // = DNSLookup + Connect time.Duration // = DNSLookup + TCPConnection + Pretransfer time.Duration // = DNSLookup + TCPConnection + TLSHandshake + StartTransfer time.Duration // = DNSLookup + TCPConnection + TLSHandshake + ServerProcessing + Total time.Duration // = DNSLookup + TCPConnection + TLSHandshake + ServerProcessing + ContentTransfer + + // internal timelines, including start and finish timestamps of each phase + dnsStart time.Time + dnsDone time.Time + tcpStart time.Time + tcpDone time.Time + tlsStart time.Time + tlsDone time.Time + serverStart time.Time + serverDone time.Time + transferStart time.Time + transferDone time.Time // need to be provided from outside + + // isTLS is true when connection seems to use TLS + isTLS bool + + // isReused is true when connection is reused (keep-alive) + isReused bool + + // https or http + schema string + + // connected network info + network, addr string +} + +// Finish sets the time when reading response is done. +// This must be called after reading response body. +func (s *Stat) Finish() { + s.transferDone = time.Now() + + // This means result is empty (it does nothing). + // Skip setting value (contentTransfer and total will be zero). + if s.dnsStart.IsZero() { + return + } + + s.ContentTransfer = s.transferDone.Sub(s.transferStart) + s.Total = s.transferDone.Sub(s.dnsStart) +} + +// Durations returns all durations and timelines of request latencies +func (s *Stat) Durations() map[string]int64 { + return map[string]int64{ + "DNSLookup": s.DNSLookup.Milliseconds(), + "TCPConnection": s.TCPConnection.Milliseconds(), + "TLSHandshake": s.TLSHandshake.Milliseconds(), + "ServerProcessing": s.ServerProcessing.Milliseconds(), + "ContentTransfer": s.ContentTransfer.Milliseconds(), + "NameLookup": s.NameLookup.Milliseconds(), + "Connect": s.Connect.Milliseconds(), + "Pretransfer": s.Pretransfer.Milliseconds(), + "StartTransfer": s.StartTransfer.Milliseconds(), + "Total": s.Total.Milliseconds(), + } +} + +func (s *Stat) Print() { + if s.network != "" && s.addr != "" { + printf("\n%s %s: %s\n", + color.CyanString("Connected to"), + color.MagentaString(s.network), + color.BlueString(s.addr), + ) + } + + switch s.schema { + case "https": + printf(colorize(httpsTemplate), + fmta(s.DNSLookup), // dns lookup + fmta(s.TCPConnection), // tcp connection + fmta(s.TLSHandshake), // tls handshake + fmta(s.ServerProcessing), // server processing + fmta(s.ContentTransfer), // content transfer + fmtb(s.NameLookup), // namelookup + fmtb(s.Connect), // connect + fmtb(s.Pretransfer), // pretransfer + fmtb(s.StartTransfer), // starttransfer + fmtb(s.Total), // total + ) + case "http": + printf(colorize(httpTemplate), + fmta(s.DNSLookup), // dns lookup + fmta(s.TCPConnection), // tcp connection + fmta(s.ServerProcessing), // server processing + fmta(s.ContentTransfer), // content transfer + fmtb(s.NameLookup), // namelookup + fmtb(s.Connect), // connect + fmtb(s.StartTransfer), // starttransfer + fmtb(s.Total), // total + ) + } + log.Info(). + Interface("httpstat(ms)", s.Durations()). + Msg("HTTP latency statistics") +} + +// WithHTTPStat is a wrapper of httptrace.WithClientTrace. +// It records the time of each httptrace hooks. +func WithHTTPStat(req *http.Request, s *Stat) context.Context { + s.schema = req.URL.Scheme + return httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{ + DNSStart: func(i httptrace.DNSStartInfo) { + s.dnsStart = time.Now() + }, + + DNSDone: func(i httptrace.DNSDoneInfo) { + s.dnsDone = time.Now() + + s.DNSLookup = s.dnsDone.Sub(s.dnsStart) + s.NameLookup = s.DNSLookup + }, + + ConnectStart: func(network, addr string) { + s.network = network + s.addr = addr + + s.tcpStart = time.Now() + + // When connecting to IP (When no DNS lookup) + if s.dnsStart.IsZero() { + s.dnsStart = s.tcpStart + s.dnsDone = s.tcpStart + } + }, + + ConnectDone: func(network, addr string, err error) { + s.tcpDone = time.Now() + s.TCPConnection = s.tcpDone.Sub(s.tcpStart) + s.Connect = s.tcpDone.Sub(s.dnsStart) + }, + + TLSHandshakeStart: func() { + s.isTLS = true + s.tlsStart = time.Now() + }, + + TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { + s.tlsDone = time.Now() + s.TLSHandshake = s.tlsDone.Sub(s.tlsStart) + s.Pretransfer = s.tlsDone.Sub(s.dnsStart) + }, + + GotConn: func(i httptrace.GotConnInfo) { + // Handle when keep alive is used and connection is reused. + // DNSStart(Done) and ConnectStart(Done) is skipped + if i.Reused { + s.isReused = true + } + }, + + WroteRequest: func(info httptrace.WroteRequestInfo) { + now := time.Now() + s.serverStart = now + + // When client doesn't use DialContext, DNS/TCP/TLS hook is not called. + if s.dnsStart.IsZero() && s.tcpStart.IsZero() { + s.dnsStart = now + s.dnsDone = now + s.tcpStart = now + s.tcpDone = now + } + + // When connection is re-used, DNS/TCP/TLS hook is not called. + if s.isReused { + s.dnsStart = now + s.dnsDone = now + s.tcpStart = now + s.tcpDone = now + s.tlsStart = now + s.tlsDone = now + } + + if s.isTLS { // https + return + } + + // http + s.TLSHandshake = 0 + s.Pretransfer = s.Connect + }, + + GotFirstResponseByte: func() { + s.serverDone = time.Now() + s.ServerProcessing = s.serverDone.Sub(s.serverStart) + s.StartTransfer = s.serverDone.Sub(s.dnsStart) + s.transferStart = s.serverDone + }, + }) +} diff --git a/hrp/internal/json/json.go b/hrp/internal/json/json.go index 859d1e28..640946e5 100644 --- a/hrp/internal/json/json.go +++ b/hrp/internal/json/json.go @@ -12,5 +12,6 @@ var ( MarshalIndent = json.MarshalIndent Unmarshal = json.Unmarshal NewDecoder = json.NewDecoder + NewEncoder = json.NewEncoder Get = json.Get ) diff --git a/hrp/internal/pytest/main.go b/hrp/internal/pytest/main.go index 7e749d4a..a1c0c88c 100644 --- a/hrp/internal/pytest/main.go +++ b/hrp/internal/pytest/main.go @@ -3,9 +3,9 @@ package pytest import ( "fmt" - "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/sdk" - "github.com/httprunner/httprunner/hrp/internal/version" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/sdk" + "github.com/httprunner/httprunner/v4/hrp/internal/version" ) func RunPytest(args []string) error { diff --git a/hrp/internal/scaffold/examples_test.go b/hrp/internal/scaffold/examples_test.go index 3ec85cb5..d480ffb6 100644 --- a/hrp/internal/scaffold/examples_test.go +++ b/hrp/internal/scaffold/examples_test.go @@ -22,4 +22,10 @@ func TestGenDemoExamples(t *testing.T) { if err != nil { t.Fatal() } + + dir = "../../../examples/demo-empty-project" + err = CreateScaffold(dir, Empty, true) + if err != nil { + t.Fatal() + } } diff --git a/hrp/internal/scaffold/main.go b/hrp/internal/scaffold/main.go index 98f5ffed..04ad8ac5 100644 --- a/hrp/internal/scaffold/main.go +++ b/hrp/internal/scaffold/main.go @@ -6,23 +6,32 @@ import ( "os" "os/exec" "path/filepath" + "time" "github.com/pkg/errors" "github.com/rs/zerolog/log" - "github.com/httprunner/funplugin/shared" - "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/sdk" + "github.com/httprunner/httprunner/v4/hrp" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/sdk" + "github.com/httprunner/httprunner/v4/hrp/internal/version" ) type PluginType string const ( + Empty PluginType = "empty" Ignore PluginType = "ignore" Py PluginType = "py" Go PluginType = "go" ) +type ProjectInfo struct { + ProjectName string `json:"project_name,omitempty" yaml:"project_name,omitempty"` + CreateTime time.Time `json:"create_time,omitempty" yaml:"create_time,omitempty"` + Version string `json:"hrp_version,omitempty" yaml:"hrp_version,omitempty"` +} + //go:embed templates/* var templatesDir embed.FS @@ -88,8 +97,20 @@ func CreateScaffold(projectName string, pluginType PluginType, force bool) error return err } + projectInfo := &ProjectInfo{ + ProjectName: filepath.Base(projectName), + CreateTime: time.Now(), + Version: version.VERSION, + } + + // dump project information to file + err := builtin.Dump2JSON(projectInfo, filepath.Join(projectName, "proj.json")) + if err != nil { + return err + } + // create .gitignore - err := CopyFile("templates/gitignore", filepath.Join(projectName, ".gitignore")) + err = CopyFile("templates/gitignore", filepath.Join(projectName, ".gitignore")) if err != nil { return err } @@ -99,10 +120,19 @@ func CreateScaffold(projectName string, pluginType PluginType, force bool) error return err } - // create demo testcases - if pluginType == Ignore { + // create project testcases + if pluginType == Empty { + // create empty project + err := CopyFile("templates/testcases/demo_empty_request.json", + filepath.Join(projectName, "testcases", "requests.json")) + if err != nil { + return err + } + return nil + } else if pluginType == Ignore { + // create project without funplugin err := CopyFile("templates/testcases/demo_without_funplugin.json", - filepath.Join(projectName, "testcases", "demo_without_funplugin.json")) + filepath.Join(projectName, "testcases", "requests.json")) if err != nil { return err } @@ -110,18 +140,24 @@ func CreateScaffold(projectName string, pluginType PluginType, force bool) error return nil } + // create project with funplugin err = CopyFile("templates/testcases/demo_with_funplugin.json", - filepath.Join(projectName, "testcases", "demo_with_funplugin.json")) + filepath.Join(projectName, "testcases", "demo.json")) + if err != nil { + return err + } + err = CopyFile("templates/testcases/demo_requests.json", + filepath.Join(projectName, "testcases", "requests.json")) if err != nil { return err } err = CopyFile("templates/testcases/demo_requests.yml", - filepath.Join(projectName, "testcases", "demo_requests.yml")) + filepath.Join(projectName, "testcases", "requests.yml")) if err != nil { return err } err = CopyFile("templates/testcases/demo_ref_testcase.yml", - filepath.Join(projectName, "testcases", "demo_ref_testcase.yml")) + filepath.Join(projectName, "testcases", "ref_testcase.yml")) if err != nil { return err } @@ -150,26 +186,9 @@ func createGoPlugin(projectName string) error { return err } err := CopyFile("templates/plugin/debugtalk.go", - filepath.Join(projectName, "plugin", "debugtalk.go")) + filepath.Join(projectName, "plugin", hrp.PluginGoSourceFile)) if err != nil { - return err - } - - // create go mod - if err := builtin.ExecCommandInDir(exec.Command("go", "mod", "init", "plugin"), 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 { - return err - } - - // build plugin debugtalk.bin - if err := builtin.ExecCommandInDir(exec.Command("go", "build", "-o", filepath.Join("..", "debugtalk.bin"), "debugtalk.go"), pluginDir); err != nil { - return err + return errors.Wrap(err, "copy debugtalk.go failed") } return nil @@ -179,13 +198,13 @@ func createPythonPlugin(projectName string) error { log.Info().Msg("start to create hashicorp python plugin") // create debugtalk.py - pluginFile := filepath.Join(projectName, "debugtalk.py") + pluginFile := filepath.Join(projectName, hrp.PluginPySourceFile) err := CopyFile("templates/plugin/debugtalk.py", pluginFile) if err != nil { return errors.Wrap(err, "copy file failed") } - _, err = builtin.EnsurePython3Venv(fmt.Sprintf("funppy>=%s", shared.Version)) + _, err = builtin.EnsurePython3Venv("funppy") if err != nil { return err } diff --git a/hrp/internal/scaffold/templates/env b/hrp/internal/scaffold/templates/env index 9b5dc360..59ecc742 100644 --- a/hrp/internal/scaffold/templates/env +++ b/hrp/internal/scaffold/templates/env @@ -1,2 +1,3 @@ +base_url=https://postman-echo.com USERNAME=debugtalk PASSWORD=123456 \ No newline at end of file diff --git a/hrp/internal/scaffold/templates/gitignore b/hrp/internal/scaffold/templates/gitignore index 33401380..4c8cb60c 100644 --- a/hrp/internal/scaffold/templates/gitignore +++ b/hrp/internal/scaffold/templates/gitignore @@ -1,4 +1,3 @@ -.env reports/ *.so .vscode/ diff --git a/hrp/internal/scaffold/templates/plugin/.debugtalk_gen.py b/hrp/internal/scaffold/templates/plugin/.debugtalk_gen.py new file mode 100644 index 00000000..d3b72a66 --- /dev/null +++ b/hrp/internal/scaffold/templates/plugin/.debugtalk_gen.py @@ -0,0 +1,75 @@ +# NOTE: Generated By hrp v4.1.1, DO NOT EDIT! + +import logging +import time +import funppy + +from typing import List + + +def get_user_agent(): + return "hrp/funppy" + + +def sleep(n_secs): + time.sleep(n_secs) + + +def sum(*args): + result = 0 + for arg in args: + result += arg + return result + + +def sum_ints(*args: List[int]) -> int: + result = 0 + for arg in args: + result += arg + return result + + +def sum_two_int(a: int, b: int) -> int: + return a + b + + +def sum_two_string(a: str, b: str) -> str: + return a + b + + +def sum_strings(*args: List[str]) -> str: + result = "" + for arg in args: + result += arg + return result + + +def concatenate(*args: List[str]) -> str: + result = "" + for arg in args: + result += str(arg) + return result + + +def setup_hook_example(name): + logging.warning("setup_hook_example") + return f"setup_hook_example: {name}" + + +def teardown_hook_example(name): + logging.warning("teardown_hook_example") + return f"teardown_hook_example: {name}" + + +if __name__ == "__main__": + 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/hrp/internal/scaffold/templates/plugin/debugtalk.go b/hrp/internal/scaffold/templates/plugin/debugtalk.go index b3b39400..73deb244 100644 --- a/hrp/internal/scaffold/templates/plugin/debugtalk.go +++ b/hrp/internal/scaffold/templates/plugin/debugtalk.go @@ -2,8 +2,6 @@ package main import ( "fmt" - - "github.com/httprunner/funplugin/fungo" ) func SumTwoInt(a, b int) int { @@ -41,17 +39,6 @@ func TeardownHookExample(args string) string { return fmt.Sprintf("step name: %v, teardown...", args) } -func GetVersion() string { - return fungo.Version -} - -func main() { - fungo.Register("get_version", GetVersion) - fungo.Register("sum_ints", SumInts) - fungo.Register("sum_two_int", SumTwoInt) - fungo.Register("sum_two", SumTwoInt) - fungo.Register("sum", Sum) - fungo.Register("setup_hook_example", SetupHookExample) - fungo.Register("teardown_hook_example", TeardownHookExample) - fungo.Serve() +func GetUserAgent() string { + return "hrp/fungo" } diff --git a/hrp/internal/scaffold/templates/plugin/debugtalk.py b/hrp/internal/scaffold/templates/plugin/debugtalk.py index 9fd41120..ea48ff48 100644 --- a/hrp/internal/scaffold/templates/plugin/debugtalk.py +++ b/hrp/internal/scaffold/templates/plugin/debugtalk.py @@ -2,11 +2,9 @@ import logging import time from typing import List -import funppy - -def get_version(): - return funppy.__version__ +def get_user_agent(): + return "hrp/funppy" def sleep(n_secs): @@ -57,17 +55,3 @@ def setup_hook_example(name): def teardown_hook_example(name): logging.warning("teardown_hook_example") return f"teardown_hook_example: {name}" - - -if __name__ == "__main__": - funppy.register("get_version", get_version) - funppy.register("sum", sum) - funppy.register("sum_ints", sum_ints) - funppy.register("concatenate", concatenate) - funppy.register("sum_two_int", sum_two_int) - funppy.register("sum_two", sum_two_int) - funppy.register("sum_two_string", sum_two_string) - funppy.register("sum_strings", sum_strings) - funppy.register("setup_hook_example", setup_hook_example) - funppy.register("teardown_hook_example", teardown_hook_example) - funppy.serve() diff --git a/hrp/internal/scaffold/templates/plugin/debugtalkGoTemplate b/hrp/internal/scaffold/templates/plugin/debugtalkGoTemplate new file mode 100644 index 00000000..d5b096bc --- /dev/null +++ b/hrp/internal/scaffold/templates/plugin/debugtalkGoTemplate @@ -0,0 +1,13 @@ +// NOTE: Generated By hrp {{ .Version }}, DO NOT EDIT! +package main + +import ( + "github.com/httprunner/funplugin/fungo" +) + +func main() { +{{- range $functionName := .FunctionNames }} + fungo.Register("{{ $functionName }}", {{ $functionName }}) +{{- end }} + fungo.Serve() +} diff --git a/hrp/internal/scaffold/templates/plugin/debugtalkPythonTemplate b/hrp/internal/scaffold/templates/plugin/debugtalkPythonTemplate new file mode 100644 index 00000000..dc16657a --- /dev/null +++ b/hrp/internal/scaffold/templates/plugin/debugtalkPythonTemplate @@ -0,0 +1,18 @@ +# NOTE: Generated By hrp {{ .Version }}, DO NOT EDIT! + +{{ range $import := .Imports }} +{{- $import}} +{{ end }} +{{ range $fromImport := .FromImports }} +{{- $fromImport}} +{{ end }} + +{{ range $function := .Functions }} +{{- $function }} +{{ end }} + +if __name__ == "__main__": +{{- range $functionName := .FunctionNames }} + funppy.register("{{ $functionName }}", {{ $functionName }}) +{{- end }} + funppy.serve() diff --git a/hrp/internal/scaffold/templates/plugin/debugtalk_gen.go b/hrp/internal/scaffold/templates/plugin/debugtalk_gen.go new file mode 100644 index 00000000..a2339a42 --- /dev/null +++ b/hrp/internal/scaffold/templates/plugin/debugtalk_gen.go @@ -0,0 +1,16 @@ +// NOTE: Generated By hrp v4.1.1, 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/hrp/internal/scaffold/templates/testcases/demo_empty_request.json b/hrp/internal/scaffold/templates/testcases/demo_empty_request.json new file mode 100644 index 00000000..fc76e4aa --- /dev/null +++ b/hrp/internal/scaffold/templates/testcases/demo_empty_request.json @@ -0,0 +1,25 @@ +{ + "config": { + "name": "request methods testcase: empty testcase", + "variables": null, + "verify": false + }, + "teststeps": [ + { + "name": "", + "variables": null, + "request": { + "method": "GET", + "url": "https://" + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + } + ] + } + ] +} \ No newline at end of file diff --git a/hrp/internal/scaffold/templates/testcases/demo_empty_request.yml b/hrp/internal/scaffold/templates/testcases/demo_empty_request.yml new file mode 100644 index 00000000..38e7c4a8 --- /dev/null +++ b/hrp/internal/scaffold/templates/testcases/demo_empty_request.yml @@ -0,0 +1,13 @@ +config: + name: "request methods testcase: empty testcase" + variables: + verify: False + +teststeps: + - name: + variables: + request: + method: GET + url: "https://" + validate: + - eq: ["status_code", 200] diff --git a/hrp/internal/scaffold/templates/testcases/demo_ref_api.json b/hrp/internal/scaffold/templates/testcases/demo_ref_api.json index 8e69392f..7bc33c5e 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_ref_api.json +++ b/hrp/internal/scaffold/templates/testcases/demo_ref_api.json @@ -8,16 +8,14 @@ "app_version": "2.8.6" }, "base_url": "https://postman-echo.com", - "herader": [ - { - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate, br", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Host": "postman-echo.com", - "User-Agent": "PostmanRuntime/7.28.4" - } - ], + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "User-Agent": "PostmanRuntime/7.28.4" + }, "verify": false, "export": [ "session_token" @@ -48,16 +46,16 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.headers.postman-token", - "ea19464c-ddd4-4724-abe9-5e2b254c2723" - ] + "check": "body.headers.postman-token", + "assert": "equal", + "expect": "ea19464c-ddd4-4724-abe9-5e2b254c2723", + "msg": "check body.headers.postman-token" } ] }, diff --git a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml index 0743488e..c0932124 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml @@ -13,7 +13,7 @@ teststeps: variables: foo1: testcase_ref_bar1 expect_foo1: testcase_ref_bar1 - testcase: testcases/demo_requests.yml + testcase: testcases/requests.yml export: - foo3 - @@ -24,10 +24,10 @@ teststeps: method: POST url: /post headers: - User-Agent: funplugin/${get_version()} + User-Agent: ${get_user_agent()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo3" + body: "foo1=$foo1&foo2=$foo3" validate: - eq: ["status_code", 200] - eq: ["body.form.foo1", "bar1"] - - eq: ["body.form.foo2", "bar21"] + - eq: ["body.form.foo2", "bar21"] \ No newline at end of file diff --git a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase_test.py b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase_test.py index 714030cd..4a57b302 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase_test.py +++ b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase_test.py @@ -1,5 +1,5 @@ # NOTE: Generated By HttpRunner v4.0.0 -# FROM: testcases/demo_ref_testcase.yml +# FROM: testcases/ref_testcase.yml import sys @@ -43,7 +43,7 @@ class TestCaseDemoRefTestcase(HttpRunner): .post("/post") .with_headers( **{ - "User-Agent": "funplugin/${get_version()}", + "User-Agent": "${get_user_agent()}", "Content-Type": "application/x-www-form-urlencoded", } ) diff --git a/hrp/internal/scaffold/templates/testcases/demo_requests.json b/hrp/internal/scaffold/templates/testcases/demo_requests.json new file mode 100644 index 00000000..75c464f9 --- /dev/null +++ b/hrp/internal/scaffold/templates/testcases/demo_requests.json @@ -0,0 +1,136 @@ +{ + "config": { + "name": "request methods testcase with functions", + "variables": { + "foo1": "config_bar1", + "foo2": "config_bar2", + "expect_foo1": "config_bar1", + "expect_foo2": "config_bar2" + }, + "headers": { + "User-Agent": "${get_user_agent()}" + }, + "base_url": "https://postman-echo.com", + "verify": false, + "export": [ + "foo3" + ] + }, + "teststeps": [ + { + "name": "get with params", + "variables": { + "foo1": "${ENV(USERNAME)}", + "foo2": "bar21", + "sum_v": "${sum_two_int(1, 2)}" + }, + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "$foo1", + "foo2": "$foo2", + "sum_v": "$sum_v" + } + }, + "extract": { + "foo3": "body.args.foo2" + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + }, + { + "check": "body.args.foo1", + "assert": "equal", + "expect": "debugtalk", + "msg": "check body.args.foo1" + }, + { + "check": "body.args.sum_v", + "assert": "equal", + "expect": "3", + "msg": "check body.args.sum_v" + }, + { + "check": "body.args.foo2", + "assert": "equal", + "expect": "bar21", + "msg": "check body.args.foo2" + } + ] + }, + { + "name": "post raw text", + "variables": { + "foo1": "bar12", + "foo3": "bar32" + }, + "request": { + "method": "POST", + "url": "/post", + "headers": { + "Content-Type": "text/plain" + }, + "body": "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + }, + { + "check": "body.data", + "assert": "equal", + "expect": "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32.", + "msg": "check body.data" + } + ] + }, + { + "name": "post form data", + "variables": { + "foo2": "bar23" + }, + "request": { + "method": "POST", + "url": "/post", + "headers": { + "Content-Type": "application/x-www-form-urlencoded" + }, + "body": "foo1=$foo1&foo2=$foo2&foo3=$foo3" + }, + "validate": [ + { + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" + }, + { + "check": "body.form.foo1", + "assert": "equal", + "expect": "$expect_foo1", + "msg": "check body.form.foo1" + }, + { + "check": "body.form.foo2", + "assert": "equal", + "expect": "bar23", + "msg": "check body.form.foo2" + }, + { + "check": "body.form.foo3", + "assert": "equal", + "expect": "bar21", + "msg": "check body.form.foo3" + } + ] + } + ] +} \ No newline at end of file diff --git a/hrp/internal/scaffold/templates/testcases/demo_requests.yml b/hrp/internal/scaffold/templates/testcases/demo_requests.yml index 86d1b9cc..1db4e4d1 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_requests.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_requests.yml @@ -5,7 +5,8 @@ config: foo2: config_bar2 expect_foo1: config_bar1 expect_foo2: config_bar2 - base_url: "https://postman-echo.com" + headers: + User-Agent: ${get_user_agent()} verify: False export: ["foo3"] @@ -13,23 +14,21 @@ teststeps: - name: get with params variables: - foo1: bar11 + foo1: ${ENV(USERNAME)} foo2: bar21 sum_v: "${sum_two_int(1, 2)}" request: method: GET - url: /get + url: $base_url/get params: foo1: $foo1 foo2: $foo2 sum_v: $sum_v - headers: - User-Agent: funplugin/${get_version()} extract: foo3: "body.args.foo2" validate: - eq: ["status_code", 200] - - eq: ["body.args.foo1", "bar11"] + - eq: ["body.args.foo1", "debugtalk"] - eq: ["body.args.sum_v", "3"] - eq: ["body.args.foo2", "bar21"] - @@ -39,11 +38,10 @@ teststeps: foo3: "bar32" request: method: POST - url: /post + url: $base_url/post headers: - User-Agent: funplugin/${get_version()} Content-Type: "text/plain" - data: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." + body: "This is expected to be sent back as part of response body: $foo1-$foo2-$foo3." validate: - eq: ["status_code", 200] - eq: ["body.data", "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32."] @@ -53,11 +51,10 @@ teststeps: foo2: bar23 request: method: POST - url: /post + url: $base_url/post headers: - User-Agent: funplugin/${get_version()} Content-Type: "application/x-www-form-urlencoded" - data: "foo1=$foo1&foo2=$foo2&foo3=$foo3" + body: "foo1=$foo1&foo2=$foo2&foo3=$foo3" validate: - eq: ["status_code", 200] - eq: ["body.form.foo1", "$expect_foo1"] diff --git a/hrp/internal/scaffold/templates/testcases/demo_requests_test.py b/hrp/internal/scaffold/templates/testcases/demo_requests_test.py index fc2ad5bb..dd65d1f0 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_requests_test.py +++ b/hrp/internal/scaffold/templates/testcases/demo_requests_test.py @@ -1,8 +1,8 @@ # NOTE: Generated By HttpRunner v4.0.0 -# FROM: testcases/demo_requests.yml +# FROM: testcases/requests.yml -from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase +from httprunner import HttpRunner, Config, Step, RunRequest class TestCaseDemoRequests(HttpRunner): @@ -30,7 +30,7 @@ class TestCaseDemoRequests(HttpRunner): ) .get("/get") .with_params(**{"foo1": "$foo1", "foo2": "$foo2", "sum_v": "$sum_v"}) - .with_headers(**{"User-Agent": "funplugin/${get_version()}"}) + .with_headers(**{"User-Agent": "${get_user_agent()}"}) .extract() .with_jmespath("body.args.foo2", "foo3") .validate() @@ -45,7 +45,7 @@ class TestCaseDemoRequests(HttpRunner): .post("/post") .with_headers( **{ - "User-Agent": "funplugin/${get_version()}", + "User-Agent": "${get_user_agent()}", "Content-Type": "text/plain", } ) @@ -65,7 +65,7 @@ class TestCaseDemoRequests(HttpRunner): .post("/post") .with_headers( **{ - "User-Agent": "funplugin/${get_version()}", + "User-Agent": "${get_user_agent()}", "Content-Type": "application/x-www-form-urlencoded", } ) diff --git a/hrp/internal/sdk/events.go b/hrp/internal/sdk/events.go index 4d957455..e2667458 100644 --- a/hrp/internal/sdk/events.go +++ b/hrp/internal/sdk/events.go @@ -5,7 +5,7 @@ import ( "net/url" "time" - "github.com/httprunner/httprunner/hrp/internal/version" + "github.com/httprunner/httprunner/v4/hrp/internal/version" ) type IEvent interface { diff --git a/hrp/internal/sdk/init.go b/hrp/internal/sdk/init.go index ae80d920..8e69d5e2 100644 --- a/hrp/internal/sdk/init.go +++ b/hrp/internal/sdk/init.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog/log" - "github.com/httprunner/httprunner/hrp/internal/version" + "github.com/httprunner/httprunner/v4/hrp/internal/version" ) const ( diff --git a/hrp/internal/version/VERSION b/hrp/internal/version/VERSION index f684230d..b572ab0b 100644 --- a/hrp/internal/version/VERSION +++ b/hrp/internal/version/VERSION @@ -1 +1 @@ -v4.0.0 \ No newline at end of file +v4.1.2 \ No newline at end of file diff --git a/hrp/internal/version/init.go b/hrp/internal/version/init.go index 65b56ebc..836ffca7 100644 --- a/hrp/internal/version/init.go +++ b/hrp/internal/version/init.go @@ -7,4 +7,4 @@ import ( //go:embed VERSION var VERSION string -const HttpRunnerMinVersion = "v4.0.0-beta" +const HttpRunnerMinVersion = "v4.1.0" diff --git a/hrp/internal/wiki/main.go b/hrp/internal/wiki/main.go new file mode 100644 index 00000000..108edca6 --- /dev/null +++ b/hrp/internal/wiki/main.go @@ -0,0 +1,18 @@ +package wiki + +import ( + "os/exec" + + "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/v4/hrp/internal/sdk" +) + +func OpenWiki() error { + sdk.SendEvent(sdk.EventTracking{ + Category: "OpenWiki", + Action: "hrp wiki", + }) + log.Info().Msgf("%s https://httprunner.com", openCmd) + return exec.Command(openCmd, "https://httprunner.com").Run() +} diff --git a/hrp/internal/wiki/open_darwin.go b/hrp/internal/wiki/open_darwin.go new file mode 100644 index 00000000..a8856c1f --- /dev/null +++ b/hrp/internal/wiki/open_darwin.go @@ -0,0 +1,3 @@ +package wiki + +const openCmd = "open" diff --git a/hrp/internal/wiki/open_linux.go b/hrp/internal/wiki/open_linux.go new file mode 100644 index 00000000..d20152c4 --- /dev/null +++ b/hrp/internal/wiki/open_linux.go @@ -0,0 +1,3 @@ +package wiki + +const openCmd = "xdg-open" diff --git a/hrp/internal/wiki/open_windows.go b/hrp/internal/wiki/open_windows.go new file mode 100644 index 00000000..981253da --- /dev/null +++ b/hrp/internal/wiki/open_windows.go @@ -0,0 +1,3 @@ +package wiki + +const openCmd = "explorer" diff --git a/hrp/parameters.go b/hrp/parameters.go index 5797f618..13d1b679 100644 --- a/hrp/parameters.go +++ b/hrp/parameters.go @@ -12,17 +12,17 @@ import ( ) type TParamsConfig struct { - Strategy iteratorStrategy `json:"strategy,omitempty" yaml:"strategy,omitempty"` // overall strategy + PickOrder iteratorPickOrder `json:"pick_order,omitempty" yaml:"pick_order,omitempty"` // overall pick-order strategy Strategies map[string]iteratorStrategy `json:"strategies,omitempty" yaml:"strategies,omitempty"` // individual strategies for each parameters Limit int `json:"limit,omitempty" yaml:"limit,omitempty"` } -type iteratorStrategy string +type iteratorPickOrder string const ( - strategySequential iteratorStrategy = "sequential" - strategyRandom iteratorStrategy = "random" - strategyUnique iteratorStrategy = "unique" + pickOrderSequential iteratorPickOrder = "sequential" + pickOrderRandom iteratorPickOrder = "random" + pickOrderUnique iteratorPickOrder = "unique" ) /* @@ -33,6 +33,11 @@ const ( */ type Parameters []map[string]interface{} +type iteratorStrategy struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + PickOrder iteratorPickOrder `json:"pick_order,omitempty" yaml:"pick_order,omitempty"` +} + func initParametersIterator(cfg *TConfig) (*ParametersIterator, error) { parameters, err := loadParameters(cfg.Parameters, cfg.Variables) if err != nil { @@ -62,15 +67,15 @@ func newParametersIterator(parameters map[string]Parameters, config *TParamsConf parametersList := make([]Parameters, 0) for paramName := range parameters { - // check parameter individual strategy + // check parameter individual pick order strategy strategy, ok := config.Strategies[paramName] - if !ok { - // default to overall strategy - strategy = config.Strategy + if !ok || strategy.PickOrder == "" { + // default to overall pick order strategy + strategy.PickOrder = config.PickOrder } - // group parameters by strategy - if strategy == strategyRandom { + // group parameters by pick order strategy + if strategy.PickOrder == pickOrderRandom { iterator.randomParameterNames = append(iterator.randomParameterNames, paramName) } else { parametersList = append(parametersList, parameters[paramName]) diff --git a/hrp/parameters_test.go b/hrp/parameters_test.go index 19bb9fae..02176415 100644 --- a/hrp/parameters_test.go +++ b/hrp/parameters_test.go @@ -137,25 +137,25 @@ func TestInitParametersIteratorCount(t *testing.T) { }, 6, // 3 * 2 * 1 }, - // default equals to set overall parameters strategy to "sequential" + // default equals to set overall parameters pick-order to "sequential" { &TConfig{ Parameters: configParameters, ParametersSetting: &TParamsConfig{ - Strategy: "sequential", + PickOrder: "sequential", }, }, 6, // 3 * 2 * 1 }, - // default equals to set each individual parameters strategy to "sequential" + // default equals to set each individual parameters pick-order to "sequential" { &TConfig{ Parameters: configParameters, ParametersSetting: &TParamsConfig{ Strategies: map[string]iteratorStrategy{ - "username-password": "sequential", - "user_agent": "sequential", - "app_version": "sequential", + "username-password": {Name: "user-info", PickOrder: "sequential"}, + "user_agent": {Name: "user-identity", PickOrder: "sequential"}, + "app_version": {Name: "app-version", PickOrder: "sequential"}, }, }, }, @@ -166,33 +166,33 @@ func TestInitParametersIteratorCount(t *testing.T) { Parameters: configParameters, ParametersSetting: &TParamsConfig{ Strategies: map[string]iteratorStrategy{ - "user_agent": "sequential", - "app_version": "sequential", + "user_agent": {Name: "user-identity", PickOrder: "sequential"}, + "app_version": {Name: "app-version", PickOrder: "sequential"}, }, }, }, 6, // 3 * 2 * 1 }, - // set overall parameters overall strategy to "random" + // set overall parameters overall pick-order to "random" // each random parameters only select one item { &TConfig{ Parameters: configParameters, ParametersSetting: &TParamsConfig{ - Strategy: "random", + PickOrder: "random", }, }, 1, // 1 * 1 * 1 }, - // set some individual parameters strategy to "random" + // set some individual parameters pick-order to "random" // this will override overall strategy { &TConfig{ Parameters: configParameters, ParametersSetting: &TParamsConfig{ Strategies: map[string]iteratorStrategy{ - "user_agent": "random", + "user_agent": {Name: "user-identity", PickOrder: "random"}, }, }, }, @@ -203,7 +203,7 @@ func TestInitParametersIteratorCount(t *testing.T) { Parameters: configParameters, ParametersSetting: &TParamsConfig{ Strategies: map[string]iteratorStrategy{ - "username-password": "random", + "username-password": {Name: "user-info", PickOrder: "random"}, }, }, }, @@ -349,7 +349,7 @@ func TestInitParametersIteratorContent(t *testing.T) { ParametersSetting: &TParamsConfig{ Limit: 5, // limit could also be greater than total Strategies: map[string]iteratorStrategy{ - "username-password": "random", + "username-password": {Name: "user-info", PickOrder: "random"}, }, }, }, diff --git a/hrp/parser.go b/hrp/parser.go index 3a0d03bb..84482e3a 100644 --- a/hrp/parser.go +++ b/hrp/parser.go @@ -9,12 +9,12 @@ import ( "regexp" "strings" + "github.com/httprunner/funplugin" + "github.com/httprunner/funplugin/shared" "github.com/maja42/goval" "github.com/rs/zerolog/log" - "github.com/httprunner/funplugin" - "github.com/httprunner/funplugin/shared" - "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" ) func newParser() *Parser { @@ -252,8 +252,14 @@ func (p *Parser) ParseString(raw string, variablesMapping map[string]interface{} // only support return at most one result value func (p *Parser) CallFunc(funcName string, arguments ...interface{}) (interface{}, error) { // call with plugin function - if p.plugin != nil && p.plugin.Has(funcName) { - return p.plugin.Call(funcName, arguments...) + if p.plugin != nil { + if p.plugin.Has(funcName) { + return p.plugin.Call(funcName, arguments...) + } + commonName := shared.ConvertCommonName(funcName) + if p.plugin.Has(commonName) { + return p.plugin.Call(commonName, arguments...) + } } // get builtin function diff --git a/hrp/plugin.go b/hrp/plugin.go index a8144797..5053b2e5 100644 --- a/hrp/plugin.go +++ b/hrp/plugin.go @@ -5,31 +5,46 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "github.com/httprunner/funplugin" "github.com/rs/zerolog/log" - "github.com/httprunner/httprunner/hrp/internal/sdk" + "github.com/httprunner/httprunner/v4/hrp/internal/sdk" ) const ( - goPluginFile = "debugtalk.so" // built from go plugin - hashicorpGoPluginFile = "debugtalk.bin" // built from hashicorp go plugin - hashicorpPyPluginFile = "debugtalk.py" // used for hashicorp python plugin + PluginGoBuiltFile = "debugtalk.so" // built from go official plugin + PluginHashicorpGoBuiltFile = "debugtalk.bin" // built from hashicorp go plugin + PluginGoSourceFile = "debugtalk.go" // golang function plugin source file + PluginGoSourceGenFile = "debugtalk_gen.go" // generated for hashicorp go plugin + PluginPySourceFile = "debugtalk.py" // python function plugin source file + PluginPySourceGenFile = ".debugtalk_gen.py" // generated for hashicorp python plugin ) -func initPlugin(path string, logOn bool) (plugin funplugin.IPlugin, pluginDir string, err error) { +const projectInfoFile = "proj.json" // used for ensuring root project + +func initPlugin(path string, logOn bool) (plugin funplugin.IPlugin, err error) { // plugin file not found if path == "" { - return nil, "", nil + return nil, nil } pluginPath, err := locatePlugin(path) if err != nil { - return nil, "", nil + return nil, nil + } + + if strings.HasSuffix(pluginPath, ".py") { + // register funppy plugin + genPyPluginPath := filepath.Join(filepath.Dir(pluginPath), PluginPySourceGenFile) + err = BuildPlugin(pluginPath, genPyPluginPath) + if err != nil { + log.Error().Err(err).Str("path", pluginPath).Msg("build plugin failed") + return nil, nil + } + pluginPath = genPyPluginPath } - // TODO: move pluginDir to funplugin - pluginDir = filepath.Dir(pluginPath) // found plugin file plugin, err = funplugin.Init(pluginPath, funplugin.WithLogOn(logOn)) @@ -63,26 +78,26 @@ func initPlugin(path string, logOn bool) (plugin funplugin.IPlugin, pluginDir st func locatePlugin(path string) (pluginPath string, err error) { // priority: hashicorp plugin (debugtalk.bin > debugtalk.py) > go plugin (debugtalk.so) - pluginPath, err = locateFile(path, hashicorpGoPluginFile) + pluginPath, err = locateFile(path, PluginHashicorpGoBuiltFile) if err == nil { return } - pluginPath, err = locateFile(path, hashicorpPyPluginFile) + pluginPath, err = locateFile(path, PluginPySourceFile) if err == nil { return } - pluginPath, err = locateFile(path, goPluginFile) + pluginPath, err = locateFile(path, PluginGoBuiltFile) if err == nil { return } + log.Warn().Err(err).Str("path", path).Msg("plugin file not found") return "", fmt.Errorf("plugin file not found") } -// locateFile searches destFile upward recursively until current -// working directory or system root dir. +// locateFile searches destFile upward recursively until system root dir func locateFile(startPath string, destFile string) (string, error) { stat, err := os.Stat(startPath) if os.IsNotExist(err) { @@ -103,12 +118,6 @@ func locateFile(startPath string, destFile string) (string, error) { return pluginPath, nil } - // current working directory - cwd, _ := os.Getwd() - if startDir == cwd { - return "", fmt.Errorf("searched to CWD, plugin file not found") - } - // system root dir parentDir, _ := filepath.Abs(filepath.Dir(startDir)) if parentDir == startDir { @@ -118,15 +127,21 @@ func locateFile(startPath string, destFile string) (string, error) { return locateFile(parentDir, destFile) } -func getProjectRootDirPath(path string) (rootDir string, err error) { +func GetProjectRootDirPath(path string) (rootDir string, err error) { pluginPath, err := locatePlugin(path) if err == nil { rootDir = filepath.Dir(pluginPath) return } + // fix: no debugtalk file in project but having proj.json created by startpeoject + projPath, err := locateFile(path, projectInfoFile) + if err == nil { + rootDir = filepath.Dir(projPath) + return + } // failed to locate project root dir - // maybe project plugin debugtalk.xx is not exist + // maybe project plugin debugtalk.xx and proj.json are not exist // use current dir instead return os.Getwd() } diff --git a/hrp/plugin_test.go b/hrp/plugin_test.go index 26e74213..27cbaa77 100644 --- a/hrp/plugin_test.go +++ b/hrp/plugin_test.go @@ -8,36 +8,36 @@ import ( func TestLocateFile(t *testing.T) { // specify target file path - _, err := locateFile(templatesDir+"plugin/debugtalk.go", "debugtalk.go") + _, err := locateFile(tmpl("plugin/debugtalk.go"), PluginGoSourceFile) if !assert.Nil(t, err) { t.Fatal() } // specify path with the same dir - _, err = locateFile(templatesDir+"plugin/debugtalk.py", "debugtalk.go") + _, err = locateFile(tmpl("plugin/debugtalk.py"), PluginGoSourceFile) if !assert.Nil(t, err) { t.Fatal() } // specify target file path dir - _, err = locateFile(templatesDir+"plugin/", "debugtalk.go") + _, err = locateFile(tmpl("plugin/"), PluginGoSourceFile) if !assert.Nil(t, err) { t.Fatal() } // specify wrong path - _, err = locateFile(".", "debugtalk.go") + _, err = locateFile(".", PluginGoSourceFile) if !assert.Error(t, err) { t.Fatal() } - _, err = locateFile("/abc", "debugtalk.go") + _, err = locateFile("/abc", PluginGoSourceFile) if !assert.Error(t, err) { t.Fatal() } } func TestLocatePythonPlugin(t *testing.T) { - _, err := locatePlugin(templatesDir + "plugin/debugtalk.py") + _, err := locatePlugin(tmpl("plugin/debugtalk.py")) if !assert.Nil(t, err) { t.Fatal() } @@ -47,7 +47,7 @@ func TestLocateGoPlugin(t *testing.T) { buildHashicorpGoPlugin() defer removeHashicorpGoPlugin() - _, err := locatePlugin(templatesDir + "debugtalk.bin") + _, err := locatePlugin(tmpl("debugtalk.bin")) if !assert.Nil(t, err) { t.Fatal() } diff --git a/hrp/response.go b/hrp/response.go index dbfd3edb..33b363cb 100644 --- a/hrp/response.go +++ b/hrp/response.go @@ -14,8 +14,8 @@ import ( "github.com/pkg/errors" "github.com/rs/zerolog/log" - "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/json" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/json" ) var fieldTags = []string{"proto", "status_code", "headers", "cookies", "body", textExtractorSubRegexp} @@ -268,7 +268,7 @@ func (v *responseObject) searchRegexp(expr string) interface{} { } match := regexpCompile.FindStringSubmatch(bodyStr) if len(match) > 1 { - return match[1] //return first matched result in parentheses + return match[1] // return first matched result in parentheses } log.Error().Str("expr", expr).Msg("search regexp failed") return expr diff --git a/hrp/runner.go b/hrp/runner.go index d21b8beb..8290b1bf 100644 --- a/hrp/runner.go +++ b/hrp/runner.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/url" + "path/filepath" "testing" "time" @@ -14,7 +15,7 @@ import ( "github.com/rs/zerolog/log" "golang.org/x/net/http2" - "github.com/httprunner/httprunner/hrp/internal/sdk" + "github.com/httprunner/httprunner/v4/hrp/internal/sdk" ) // Run starts to run API test with default configs. @@ -54,6 +55,7 @@ func NewRunner(t *testing.T) *HRPRunner { type HRPRunner struct { t *testing.T failfast bool + httpStatOn bool requestsLogOn bool pluginLogOn bool saveTests bool @@ -100,6 +102,13 @@ func (r *HRPRunner) SetRequestsLogOn() *HRPRunner { return r } +// SetHTTPStatOn turns on HTTP latency stat. +func (r *HRPRunner) SetHTTPStatOn() *HRPRunner { + log.Info().Msg("[init] SetHTTPStatOn") + r.httpStatOn = true + return r +} + // SetPluginLogOn turns on plugin logging. func (r *HRPRunner) SetPluginLogOn() *HRPRunner { log.Info().Msg("[init] SetPluginLogOn") @@ -157,6 +166,7 @@ func (r *HRPRunner) Run(testcases ...ITestCase) error { return err } + var runErr error // run testcase one by one for _, testcase := range testCases { sessionRunner, err := r.NewSessionRunner(testcase) @@ -171,12 +181,14 @@ func (r *HRPRunner) Run(testcases ...ITestCase) error { }() for it := sessionRunner.parametersIterator; it.HasNext(); { - if err = sessionRunner.Start(it.Next()); err != nil { - log.Error().Err(err).Msg("[Run] run testcase failed") - return err - } + err = sessionRunner.Start(it.Next()) caseSummary := sessionRunner.GetSummary() s.appendCaseSummary(caseSummary) + if err != nil { + log.Error().Err(err).Msg("[Run] run testcase failed") + runErr = err + break + } } } s.Time.Duration = time.Since(s.Time.StartAt).Seconds() @@ -196,7 +208,8 @@ func (r *HRPRunner) Run(testcases ...ITestCase) error { return err } } - return nil + + return runErr } // NewSessionRunner creates a new session runner for testcase. @@ -222,12 +235,14 @@ func (r *HRPRunner) newCaseRunner(testcase *TestCase) (*testCaseRunner, error) { } // init parser plugin - plugin, pluginDir, err := initPlugin(testcase.Config.Path, r.pluginLogOn) + plugin, err := initPlugin(testcase.Config.Path, r.pluginLogOn) if err != nil { return nil, errors.Wrap(err, "init plugin failed") } - runner.parser.plugin = plugin - runner.rootDir = pluginDir + if plugin != nil { + runner.parser.plugin = plugin + runner.rootDir = filepath.Dir(plugin.Path()) + } // parse testcase config if err := runner.parseConfig(); err != nil { @@ -279,6 +294,25 @@ func (r *testCaseRunner) parseConfig() error { } r.parsedConfig.BaseURL = convertString(parsedBaseURL) + // merge config environment variables with base_url + // priority: env base_url > base_url + if cfg.Environs != nil { + r.parsedConfig.Environs = cfg.Environs + } else { + r.parsedConfig.Environs = make(map[string]string) + } + if value, ok := r.parsedConfig.Environs["base_url"]; !ok || value == "" { + if r.parsedConfig.BaseURL != "" { + r.parsedConfig.Environs["base_url"] = r.parsedConfig.BaseURL + } + } + + // merge config variables with environment variables + // priority: env > config variables + for k, v := range r.parsedConfig.Environs { + r.parsedConfig.Variables[k] = v + } + // ensure correction of think time config r.parsedConfig.ThinkTimeSetting.checkThinkTime() diff --git a/hrp/runner_test.go b/hrp/runner_test.go index 25eaca68..9c169bc1 100644 --- a/hrp/runner_test.go +++ b/hrp/runner_test.go @@ -1,21 +1,18 @@ package hrp import ( + "io/ioutil" "os" "testing" "time" "github.com/rs/zerolog/log" "github.com/stretchr/testify/assert" - - "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/scaffold" ) func buildHashicorpGoPlugin() { log.Info().Msg("[init] build hashicorp go plugin") - err := builtin.ExecCommand("go", "build", - "-o", templatesDir+"debugtalk.bin", templatesDir+"plugin/debugtalk.go") + 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) @@ -24,22 +21,24 @@ func buildHashicorpGoPlugin() { func removeHashicorpGoPlugin() { log.Info().Msg("[teardown] remove hashicorp go plugin") - os.Remove(templatesDir + "debugtalk.bin") + os.Remove(tmpl("debugtalk.bin")) } func buildHashicorpPyPlugin() { log.Info().Msg("[init] prepare hashicorp python plugin") - pluginFile := templatesDir + "debugtalk.py" - err := scaffold.CopyFile("templates/plugin/debugtalk.py", pluginFile) + src, _ := ioutil.ReadFile(tmpl("plugin/debugtalk.py")) + err := ioutil.WriteFile(tmpl("debugtalk.py"), src, 0o644) if err != nil { - log.Error().Err(err).Msg("build hashicorp python plugin failed") + log.Error().Err(err).Msg("copy hashicorp python plugin failed") os.Exit(1) } } func removeHashicorpPyPlugin() { log.Info().Msg("[teardown] remove hashicorp python plugin") - os.Remove(templatesDir + "debugtalk.py") + // on v4.1^, running case will generate .debugtalk_gen.py used by python plugin + os.Remove(tmpl(PluginPySourceFile)) + os.Remove(tmpl(PluginPySourceGenFile)) } func TestRunCaseWithGoPlugin(t *testing.T) { @@ -57,6 +56,7 @@ func TestRunCaseWithPythonPlugin(t *testing.T) { } func assertRunTestCases(t *testing.T) { + refCase := TestCasePath(demoTestCaseWithPluginJSONPath) testcase1 := &TestCase{ Config: NewConfig("TestCase1"). SetBaseURL("http://httpbin.org"), @@ -83,7 +83,7 @@ func assertRunTestCases(t *testing.T) { }, }, ), - NewStep("testcase1-step4").CallRefCase(&demoTestCaseWithPluginJSONPath), + NewStep("testcase1-step4").CallRefCase(&refCase), }, } testcase2 := &TestCase{ @@ -160,7 +160,8 @@ func TestRunCaseWithPluginJSON(t *testing.T) { buildHashicorpGoPlugin() defer removeHashicorpGoPlugin() - err := NewRunner(nil).Run(&demoTestCaseWithPluginJSONPath) // hrp.Run(testCase) + testCase := TestCasePath(demoTestCaseWithPluginJSONPath) + err := NewRunner(nil).Run(&testCase) // hrp.Run(testCase) if err != nil { t.Fatal() } @@ -170,7 +171,8 @@ func TestRunCaseWithPluginYAML(t *testing.T) { buildHashicorpGoPlugin() defer removeHashicorpGoPlugin() - err := NewRunner(nil).Run(&demoTestCaseWithPluginYAMLPath) // hrp.Run(testCase) + testCase := TestCasePath(demoTestCaseWithPluginYAMLPath) + err := NewRunner(nil).Run(&testCase) // hrp.Run(testCase) if err != nil { t.Fatal() } @@ -180,16 +182,18 @@ func TestRunCaseWithRefAPI(t *testing.T) { buildHashicorpGoPlugin() defer removeHashicorpGoPlugin() - err := NewRunner(nil).Run(&demoTestCaseWithRefAPIPath) + testCase := TestCasePath(demoTestCaseWithRefAPIPath) + err := NewRunner(nil).Run(&testCase) if err != nil { t.Fatal() } + refAPI := APIPath(demoAPIGETPath) testcase := &TestCase{ Config: NewConfig("TestCase"). SetBaseURL("https://postman-echo.com"), TestSteps: []IStep{ - NewStep("run referenced api").CallRefAPI(&demoAPIGETPath), + NewStep("run referenced api").CallRefAPI(&refAPI), }, } @@ -207,7 +211,7 @@ func TestLoadTestCases(t *testing.T) { if !assert.Nil(t, err) { t.Fatal() } - if !assert.Equal(t, len(testCases), 3) { + if !assert.Equal(t, 4, len(testCases)) { t.Fatal() } @@ -217,12 +221,12 @@ func TestLoadTestCases(t *testing.T) { if !assert.Nil(t, err) { t.Fatal() } - if !assert.Equal(t, len(testCases), 3) { + if !assert.Equal(t, len(testCases), 4) { t.Fatal() } // load test cases from single file path - tc = demoTestCaseWithPluginJSONPath + tc = TestCasePath(demoTestCaseWithPluginJSONPath) testCases, err = LoadTestCases(&tc) if !assert.Nil(t, err) { t.Fatal() diff --git a/hrp/session.go b/hrp/session.go index 96710284..eae3b41a 100644 --- a/hrp/session.go +++ b/hrp/session.go @@ -42,6 +42,10 @@ 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 } diff --git a/hrp/step.go b/hrp/step.go index 242f9294..b4583852 100644 --- a/hrp/step.go +++ b/hrp/step.go @@ -17,6 +17,7 @@ type StepResult struct { StepType StepType `json:"step_type" yaml:"step_type"` // step type, testcase/request/transaction/rendezvous Success bool `json:"success" yaml:"success"` // step execution result Elapsed int64 `json:"elapsed_ms" yaml:"elapsed_ms"` // step execution time in millisecond(ms) + HttpStat map[string]int64 `json:"httpstat,omitempty" yaml:"httpstat,omitempty"` // httpstat in millisecond(ms) 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 diff --git a/hrp/step_api.go b/hrp/step_api.go index 292a616f..1c9992ba 100644 --- a/hrp/step_api.go +++ b/hrp/step_api.go @@ -3,7 +3,7 @@ package hrp import ( "fmt" - "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" ) // IAPI represents interface for api, diff --git a/hrp/step_request.go b/hrp/step_request.go index 64994657..f890a93e 100644 --- a/hrp/step_request.go +++ b/hrp/step_request.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/gzip" "compress/zlib" + "crypto/tls" "fmt" "io" "net/http" @@ -15,11 +16,13 @@ import ( "time" "github.com/andybalholm/brotli" + "github.com/fatih/color" "github.com/pkg/errors" "github.com/rs/zerolog/log" - "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/json" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/httpstat" + "github.com/httprunner/httprunner/v4/hrp/internal/json" ) type HTTPMethod string @@ -142,7 +145,11 @@ func (r *requestBuilder) prepareUrlParams(stepVariables map[string]interface{}) log.Error().Err(err).Msg("parse request url failed") return err } - rawUrl := buildURL(r.config.BaseURL, convertString(requestUrl)) + var baseURL string + if stepVariables["base_url"] != nil { + baseURL = stepVariables["base_url"].(string) + } + rawUrl := buildURL(baseURL, convertString(requestUrl)) // prepare request params var queryParams url.Values @@ -311,6 +318,13 @@ func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err } } + // stat HTTP request + var httpStat httpstat.Stat + if r.HTTPStatOn() { + ctx := httpstat.WithHTTPStat(rb.req, &httpStat) + rb.req = rb.req.WithContext(ctx) + } + // do request action start := time.Now() var resp *http.Response @@ -319,8 +333,6 @@ func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err } else { resp, err = r.hrpRunner.httpClient.Do(rb.req) } - - stepResult.Elapsed = time.Since(start).Milliseconds() if err != nil { return stepResult, errors.Wrap(err, "do request failed") } @@ -346,6 +358,14 @@ func runStepRequest(r *SessionRunner, step *TStep) (stepResult *StepResult, err return } + stepResult.Elapsed = time.Since(start).Milliseconds() + if r.HTTPStatOn() { + // resp.Body has been ReadAll + httpStat.Finish() + stepResult.HttpStat = httpStat.Durations() + httpStat.Print() + } + // add response object to step variables, could be used in teardown hooks stepVariables["hrp_step_response"] = respObj.respObjMeta @@ -408,8 +428,22 @@ func printRequest(req *http.Request) error { return nil } +func printf(format string, a ...interface{}) (n int, err error) { + return fmt.Fprintf(color.Output, format, a...) +} + func printResponse(resp *http.Response) error { fmt.Println("==================== response ====================") + connectedVia := "plaintext" + if resp.TLS != nil { + switch resp.TLS.Version { + case tls.VersionTLS12: + connectedVia = "TLSv1.2" + case tls.VersionTLS13: + connectedVia = "TLSv1.3" + } + } + printf("%s %s\n", color.CyanString("Connected via"), color.BlueString("%s", connectedVia)) respContentType := resp.Header.Get("Content-Type") printBody := shouldPrintBody(respContentType) respDump, err := httputil.DumpResponse(resp, printBody) @@ -1031,6 +1065,17 @@ func (s *StepRequestValidation) AssertStringEqual(jmesPath string, expected inte return s } +func (s *StepRequestValidation) AssertEqualFold(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "equal_fold", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + func (s *StepRequestValidation) AssertLengthLessOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation { v := Validator{ Check: jmesPath, diff --git a/hrp/step_request_test.go b/hrp/step_request_test.go index 234119f4..1076b31d 100644 --- a/hrp/step_request_test.go +++ b/hrp/step_request_test.go @@ -2,6 +2,8 @@ package hrp import ( "testing" + + "github.com/stretchr/testify/assert" ) var ( @@ -89,3 +91,84 @@ func TestRunRequestRun(t *testing.T) { 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) + if err := sessionRunner.Start(nil); err != nil { + t.Fatal() + } + summary := sessionRunner.GetSummary() + + stat := summary.Records[0].HttpStat + if !assert.GreaterOrEqual(t, stat["DNSLookup"], int64(0)) { + t.Fatal() + } + if !assert.Greater(t, stat["TCPConnection"], int64(0)) { + t.Fatal() + } + if !assert.Greater(t, stat["TLSHandshake"], int64(0)) { + t.Fatal() + } + if !assert.Greater(t, stat["ServerProcessing"], int64(1)) { + t.Fatal() + } + if !assert.GreaterOrEqual(t, stat["ContentTransfer"], int64(0)) { + t.Fatal() + } + if !assert.GreaterOrEqual(t, stat["NameLookup"], int64(0)) { + t.Fatal() + } + if !assert.Greater(t, stat["Connect"], int64(0)) { + t.Fatal() + } + if !assert.Greater(t, stat["Pretransfer"], int64(0)) { + t.Fatal() + } + if !assert.Greater(t, stat["StartTransfer"], int64(0)) { + t.Fatal() + } + if !assert.Greater(t, stat["Total"], int64(5)) { + t.Fatal() + } + if !assert.Less(t, stat["Total"]-summary.Records[0].Elapsed, int64(3)) { + t.Fatal() + } + + // reuse connection + stat = summary.Records[1].HttpStat + if !assert.Equal(t, int64(0), stat["DNSLookup"]) { + t.Fatal() + } + if !assert.Equal(t, int64(0), stat["TCPConnection"]) { + t.Fatal() + } + if !assert.Equal(t, int64(0), stat["TLSHandshake"]) { + t.Fatal() + } + if !assert.Greater(t, stat["ServerProcessing"], int64(1)) { + t.Fatal() + } + if !assert.Equal(t, int64(0), stat["NameLookup"]) { + t.Fatal() + } + if !assert.Equal(t, int64(0), stat["Connect"]) { + t.Fatal() + } + if !assert.Equal(t, int64(0), stat["Pretransfer"]) { + t.Fatal() + } + if !assert.Greater(t, stat["StartTransfer"], int64(0)) { + t.Fatal() + } + if !assert.Greater(t, stat["Total"], int64(1)) { + t.Fatal() + } + if !assert.Less(t, stat["Total"]-summary.Records[0].Elapsed, int64(3)) { + t.Fatal() + } +} diff --git a/hrp/step_testcase.go b/hrp/step_testcase.go index 47151096..e75e52e4 100644 --- a/hrp/step_testcase.go +++ b/hrp/step_testcase.go @@ -1,6 +1,7 @@ package hrp import ( + "fmt" "time" "github.com/jinzhu/copier" @@ -88,6 +89,10 @@ func (s *StepTestCaseWithOptionalArgs) Run(r *SessionRunner) (*StepResult, error return stepResult, err } summary := sessionRunner.GetSummary() + // update step names + for _, record := range summary.Records { + record.Name = fmt.Sprintf("%s - %s", stepResult.Name, record.Name) + } stepResult.Data = summary.Records // export testcase export variables stepResult.ExportVars = summary.InOut.ExportVars diff --git a/hrp/step_thinktime.go b/hrp/step_thinktime.go index 9abb20d5..6b14b462 100644 --- a/hrp/step_thinktime.go +++ b/hrp/step_thinktime.go @@ -3,8 +3,9 @@ package hrp import ( "time" - "github.com/httprunner/httprunner/hrp/internal/builtin" "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" ) type ThinkTime struct { diff --git a/hrp/step_websocket.go b/hrp/step_websocket.go index 0db5ffae..f3a788fd 100644 --- a/hrp/step_websocket.go +++ b/hrp/step_websocket.go @@ -3,15 +3,17 @@ package hrp import ( "bytes" "fmt" - "github.com/gorilla/websocket" - "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/json" - "github.com/pkg/errors" - "github.com/rs/zerolog/log" "net/http" "testing" "time" "unsafe" + + "github.com/gorilla/websocket" + "github.com/pkg/errors" + "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/summary.go b/hrp/summary.go index 42dc37d7..13e6085a 100644 --- a/hrp/summary.go +++ b/hrp/summary.go @@ -10,9 +10,10 @@ import ( "runtime" "time" - "github.com/httprunner/httprunner/hrp/internal/builtin" - "github.com/httprunner/httprunner/hrp/internal/version" "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/version" ) func newOutSummary() *Summary { @@ -72,7 +73,7 @@ func (s *Summary) genHTMLReport() error { } reportPath := filepath.Join(reportsDir, fmt.Sprintf("report-%v.html", s.Time.StartAt.Unix())) - file, err := os.OpenFile(reportPath, os.O_WRONLY|os.O_CREATE, 0666) + file, err := os.OpenFile(reportPath, os.O_WRONLY|os.O_CREATE, 0o666) if err != nil { log.Error().Err(err).Msg("open file failed") return err diff --git a/hrp/testcase.go b/hrp/testcase.go index ce6a8106..4182cec7 100644 --- a/hrp/testcase.go +++ b/hrp/testcase.go @@ -7,9 +7,10 @@ import ( "path/filepath" "strings" - "github.com/httprunner/httprunner/hrp/internal/builtin" "github.com/pkg/errors" "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" ) // ITestCase represents interface for testcases, @@ -59,8 +60,11 @@ func (path *TestCasePath) ToTestCase() (*TestCase, error) { if err != nil { return nil, err } + if tc.Config == nil { + return nil, errors.New("incorrect testcase file format, expected config in file") + } - err = tc.makeCompat() + err = tc.MakeCompat() if err != nil { return nil, err } @@ -71,11 +75,30 @@ func (path *TestCasePath) ToTestCase() (*TestCase, error) { } // locate project root dir by plugin path - projectRootDir, err := getProjectRootDirPath(casePath) + projectRootDir, err := GetProjectRootDirPath(casePath) if err != nil { return nil, errors.Wrap(err, "failed to get project root dir") } + // load .env file + dotEnvPath := filepath.Join(projectRootDir, ".env") + if builtin.IsFilePathExists(dotEnvPath) { + envVars := make(map[string]string) + err = builtin.LoadFile(dotEnvPath, envVars) + if err != nil { + return nil, errors.Wrap(err, "failed to load .env file") + } + + // override testcase config env with variables loaded from .env file + // priority: .env file > testcase config env + if testCase.Config.Environs == nil { + testCase.Config.Environs = make(map[string]string) + } + for key, value := range envVars { + testCase.Config.Environs[key] = value + } + } + for _, step := range tc.TestSteps { if step.API != nil { apiPath, ok := step.API.(string) @@ -150,26 +173,27 @@ type TCase struct { TestSteps []*TStep `json:"teststeps" yaml:"teststeps"` } -// makeCompat converts TCase to compatible testcase -func (tc *TCase) makeCompat() error { - var err error +// MakeCompat converts TCase compatible with Golang engine style +func (tc *TCase) MakeCompat() (err error) { defer func() { if p := recover(); p != nil { - err = fmt.Errorf("convert compat testcase error: %v", p) + err = fmt.Errorf("[MakeCompat] convert compat testcase error: %v", p) } }() for _, step := range tc.TestSteps { - // 1. deal with request body compatible with HttpRunner + // 1. deal with request body compatibility if step.Request != nil && step.Request.Body == nil { if step.Request.Json != nil { step.Request.Headers["Content-Type"] = "application/json; charset=utf-8" step.Request.Body = step.Request.Json + step.Request.Json = nil } else if step.Request.Data != nil { step.Request.Body = step.Request.Data + step.Request.Data = nil } } - // 2. deal with validators compatible with HttpRunner + // 2. deal with validators compatibility err = convertCompatValidator(step.Validators) if err != nil { return err @@ -183,38 +207,46 @@ func (tc *TCase) makeCompat() error { func convertCompatValidator(Validators []interface{}) (err error) { for i, iValidator := range Validators { + if _, ok := iValidator.(Validator); ok { + continue + } validatorMap := iValidator.(map[string]interface{}) validator := Validator{} - _, checkExisted := validatorMap["check"] - _, assertExisted := validatorMap["assert"] - _, expectExisted := validatorMap["expect"] - // check priority: HRP > HttpRunner + iCheck, checkExisted := validatorMap["check"] + iAssert, assertExisted := validatorMap["assert"] + iExpect, expectExisted := validatorMap["expect"] + // validator check priority: Golang > Python engine style if checkExisted && assertExisted && expectExisted { - // HRP validator format - validator.Check = validatorMap["check"].(string) - validator.Assert = validatorMap["assert"].(string) - validator.Expect = validatorMap["expect"] - if msg, existed := validatorMap["msg"]; existed { - validator.Message = msg.(string) + // Golang engine style + validator.Check = iCheck.(string) + validator.Assert = iAssert.(string) + validator.Expect = iExpect + if iMsg, msgExisted := validatorMap["msg"]; msgExisted { + validator.Message = iMsg.(string) } validator.Check = convertCheckExpr(validator.Check) Validators[i] = validator - } else if len(validatorMap) == 1 { - // HttpRunner validator format + continue + } + if len(validatorMap) == 1 { + // Python engine style for assertMethod, iValidatorContent := range validatorMap { - checkAndExpect := iValidatorContent.([]interface{}) - if len(checkAndExpect) != 2 { + validatorContent := iValidatorContent.([]interface{}) + if len(validatorContent) > 3 { return fmt.Errorf("unexpected validator format: %v", validatorMap) } - validator.Check = checkAndExpect[0].(string) + validator.Check = validatorContent[0].(string) validator.Assert = assertMethod - validator.Expect = checkAndExpect[1] + validator.Expect = validatorContent[1] + if len(validatorContent) == 3 { + validator.Message = validatorContent[2].(string) + } } validator.Check = convertCheckExpr(validator.Check) Validators[i] = validator - } else { - return fmt.Errorf("unexpected validator format: %v", validatorMap) + continue } + return fmt.Errorf("unexpected validator format: %v", validatorMap) } return nil } @@ -283,8 +315,8 @@ func LoadTestCases(iTestCases ...ITestCase) ([]*TestCase, error) { testCasePath := TestCasePath(path) tc, err := testCasePath.ToTestCase() if err != nil { - log.Error().Err(err).Str("path", path).Msg("load testcase failed") - return errors.Wrap(err, "load testcase failed") + log.Warn().Err(err).Str("path", path).Msg("load testcase failed") + return nil } testCases = append(testCases, tc) return nil diff --git a/hrp/testcase_test.go b/hrp/testcase_test.go index e49eccfe..c55642e1 100644 --- a/hrp/testcase_test.go +++ b/hrp/testcase_test.go @@ -1,31 +1,34 @@ package hrp import ( + "path/filepath" "testing" "github.com/stretchr/testify/assert" - "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" ) const ( - templatesDir = "internal/scaffold/templates/" hrpExamplesDir = "../examples/hrp" ) -var ( - demoTestCaseWithPluginJSONPath TestCasePath = templatesDir + "testcases/demo_with_funplugin.json" - demoTestCaseWithPluginYAMLPath TestCasePath = templatesDir + "testcases/demo_with_funplugin.yaml" - demoTestCaseWithoutPluginJSONPath TestCasePath = templatesDir + "testcases/demo_without_funplugin.json" - demoTestCaseWithoutPluginYAMLPath TestCasePath = templatesDir + "testcases/demo_without_funplugin.yaml" - demoTestCaseWithRefAPIPath TestCasePath = templatesDir + "testcases/demo_ref_api.json" - demoAPIGETPath APIPath = templatesDir + "/api/get.yml" -) +// tmpl returns template file path +func tmpl(relativePath string) string { + return filepath.Join("internal/scaffold/templates/", relativePath) +} var ( - demoTestCaseWithThinkTimePath TestCasePath = hrpExamplesDir + "/think_time_test.json" + demoTestCaseWithPluginJSONPath = tmpl("testcases/demo_with_funplugin.json") + demoTestCaseWithPluginYAMLPath = tmpl("testcases/demo_with_funplugin.yaml") + demoTestCaseWithoutPluginJSONPath = tmpl("testcases/demo_without_funplugin.json") + demoTestCaseWithoutPluginYAMLPath = tmpl("testcases/demo_without_funplugin.yaml") + demoTestCaseWithRefAPIPath = tmpl("testcases/demo_ref_api.json") + demoAPIGETPath = tmpl("/api/get.yml") ) +var demoTestCaseWithThinkTimePath TestCasePath = hrpExamplesDir + "/think_time_test.json" + var demoTestCaseWithPlugin = &TestCase{ Config: NewConfig("demo with complex mechanisms"). SetBaseURL("https://postman-echo.com"). @@ -154,21 +157,21 @@ var demoTestCaseWithoutPlugin = &TestCase{ func TestGenDemoTestCase(t *testing.T) { tCase := demoTestCaseWithPlugin.ToTCase() - err := builtin.Dump2JSON(tCase, demoTestCaseWithPluginJSONPath.GetPath()) + err := builtin.Dump2JSON(tCase, demoTestCaseWithPluginJSONPath) if err != nil { t.Fatal() } - err = builtin.Dump2YAML(tCase, demoTestCaseWithPluginYAMLPath.GetPath()) + err = builtin.Dump2YAML(tCase, demoTestCaseWithPluginYAMLPath) if err != nil { t.Fatal() } tCase = demoTestCaseWithoutPlugin.ToTCase() - err = builtin.Dump2JSON(tCase, demoTestCaseWithoutPluginJSONPath.GetPath()) + err = builtin.Dump2JSON(tCase, demoTestCaseWithoutPluginJSONPath) if err != nil { t.Fatal() } - err = builtin.Dump2YAML(tCase, demoTestCaseWithoutPluginYAMLPath.GetPath()) + err = builtin.Dump2YAML(tCase, demoTestCaseWithoutPluginYAMLPath) if err != nil { t.Fatal() } @@ -177,11 +180,11 @@ func TestGenDemoTestCase(t *testing.T) { func TestLoadCase(t *testing.T) { tcJSON := &TCase{} tcYAML := &TCase{} - err := builtin.LoadFile(demoTestCaseWithPluginJSONPath.GetPath(), tcJSON) + err := builtin.LoadFile(demoTestCaseWithPluginJSONPath, tcJSON) if !assert.NoError(t, err) { t.Fatal() } - err = builtin.LoadFile(demoTestCaseWithPluginYAMLPath.GetPath(), tcYAML) + err = builtin.LoadFile(demoTestCaseWithPluginYAMLPath, tcYAML) if !assert.NoError(t, err) { t.Fatal() } diff --git a/hrp/tests/extract_test.go b/hrp/tests/extract_test.go index e16f2a52..df8d2d18 100644 --- a/hrp/tests/extract_test.go +++ b/hrp/tests/extract_test.go @@ -3,7 +3,7 @@ package tests import ( "testing" - "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/v4/hrp" ) // reference extracted variables for validation in the same step diff --git a/hrp/tests/function_test.go b/hrp/tests/function_test.go index 09dfa2f1..a4cc5d01 100644 --- a/hrp/tests/function_test.go +++ b/hrp/tests/function_test.go @@ -3,7 +3,7 @@ package tests import ( "testing" - "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/v4/hrp" ) func TestCaseCallFunction(t *testing.T) { @@ -28,7 +28,7 @@ func TestCaseCallFunction(t *testing.T) { AssertLengthEqual("body.args.foo1", 5, "check args foo1"). AssertEqual("body.args.foo2", "12.3", "check args foo2"). AssertTypeMatch("body.args.foo3", "str", "check args foo3 is type string"). - AssertStringEqual("body.args.foo3", "foo3", "check args foo3 case-insensitivity"). + AssertEqualFold("body.args.foo3", "foo3", "check args foo3 case-insensitivity"). AssertContains("body.args.foo3", "Foo", "check contains "). AssertContainedBy("body.args.foo3", "this is Foo3 test", "check contained by"), // notice: request params value will be converted to string hrp.NewStep("post json data with functions"). diff --git a/hrp/tests/protocol_test.go b/hrp/tests/protocol_test.go index b093bb39..42e98207 100644 --- a/hrp/tests/protocol_test.go +++ b/hrp/tests/protocol_test.go @@ -3,7 +3,7 @@ package tests import ( "testing" - "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/v4/hrp" ) func TestHTTPProtocol(t *testing.T) { diff --git a/hrp/tests/rendezvous_test.go b/hrp/tests/rendezvous_test.go index ce68c2e5..59e03ba6 100644 --- a/hrp/tests/rendezvous_test.go +++ b/hrp/tests/rendezvous_test.go @@ -3,7 +3,7 @@ package tests import ( "testing" - "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/v4/hrp" ) func TestRendezvous(t *testing.T) { diff --git a/hrp/tests/request_test.go b/hrp/tests/request_test.go index 59f228df..bc0cc08e 100644 --- a/hrp/tests/request_test.go +++ b/hrp/tests/request_test.go @@ -3,7 +3,7 @@ package tests import ( "testing" - "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/v4/hrp" ) func TestCaseBasicRequest(t *testing.T) { diff --git a/hrp/tests/validate_test.go b/hrp/tests/validate_test.go index 94922a98..3b94b1d4 100644 --- a/hrp/tests/validate_test.go +++ b/hrp/tests/validate_test.go @@ -3,7 +3,7 @@ package tests import ( "testing" - "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/v4/hrp" ) func TestCaseValidateStep(t *testing.T) { diff --git a/hrp/tests/variables_test.go b/hrp/tests/variables_test.go index 1bc73d6a..b9659654 100644 --- a/hrp/tests/variables_test.go +++ b/hrp/tests/variables_test.go @@ -3,7 +3,7 @@ package tests import ( "testing" - "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/v4/hrp" ) func TestCaseConfigVariables(t *testing.T) { diff --git a/httprunner/__init__.py b/httprunner/__init__.py index 32ac1242..aba58277 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1,4 +1,4 @@ -__version__ = "v4.0.0" +__version__ = "v4.1.2" __description__ = "One-stop solution for HTTP(S) testing." diff --git a/httprunner/compat.py b/httprunner/compat.py index c7352f6a..4732783a 100644 --- a/httprunner/compat.py +++ b/httprunner/compat.py @@ -1,5 +1,5 @@ """ -This module handles compatibility issues between testcase format v2 and v3. +This module handles compatibility issues between testcase format v2, v3 and v4. """ import os import sys @@ -14,9 +14,8 @@ from httprunner.utils import sort_dict_by_custom_order def convert_variables( - raw_variables: Union[Dict, Text], test_path: Text + raw_variables: Union[Dict, Text], test_path: Text ) -> Dict[Text, Any]: - if isinstance(raw_variables, Dict): return raw_variables @@ -33,6 +32,18 @@ def convert_variables( ) +def _convert_request(request: Dict) -> Dict: + if "body" in request: + content_type = "" + if "headers" in request and "Content-Type" in request["headers"]: + content_type = request["headers"]["Content-Type"] + if content_type.startswith("application/json"): + request["json"] = request.pop("body") + else: + request["data"] = request.pop("body") + return _sort_request_by_custom_order(request) + + def _convert_jmespath(raw: Text) -> Text: if not isinstance(raw, Text): raise exceptions.TestCaseFormatError(f"Invalid jmespath extractor: {raw}") @@ -153,6 +164,9 @@ def _ensure_step_attachment(step: Dict) -> Dict: "name": step["name"], } + if "request" in step: + test_dict["request"] = _convert_request(step["request"]) + if "variables" in step: test_dict["variables"] = step["variables"] @@ -181,11 +195,11 @@ def _ensure_step_attachment(step: Dict) -> Dict: return test_dict -def ensure_testcase_v3_api(api_content: Dict) -> Dict: - logger.info("convert api in v2 to testcase format v3") +def ensure_testcase_v4_api(api_content: Dict) -> Dict: + logger.info("convert api in v2/v3 to testcase format v4") teststep = { - "request": _sort_request_by_custom_order(api_content["request"]), + "request": _convert_request(api_content["request"]), } teststep.update(_ensure_step_attachment(api_content)) @@ -202,8 +216,8 @@ def ensure_testcase_v3_api(api_content: Dict) -> Dict: } -def ensure_testcase_v3(test_content: Dict) -> Dict: - logger.info("ensure compatibility with testcase format v2") +def ensure_testcase_v4(test_content: Dict) -> Dict: + logger.info("ensure compatibility with testcase format v2/v3") v3_content = {"config": test_content["config"], "teststeps": []} @@ -221,7 +235,7 @@ def ensure_testcase_v3(test_content: Dict) -> Dict: teststep = {} if "request" in step: - teststep["request"] = _sort_request_by_custom_order(step.pop("request")) + pass elif "api" in step: teststep["testcase"] = step.pop("api") elif "testcase" in step: diff --git a/httprunner/compat_test.py b/httprunner/compat_test.py index 391133a1..a7877fb6 100644 --- a/httprunner/compat_test.py +++ b/httprunner/compat_test.py @@ -26,6 +26,53 @@ class TestCompat(unittest.TestCase): with self.assertRaises(exceptions.TestCaseFormatError): compat.convert_variables(None, "examples/data/a-b.c/1.yml") + def test_convert_request(self): + request_with_json_body = { + "method": "POST", + "url": "https://postman-echo.com/post", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "k1": "v1", + "k2": "v2" + } + } + self.assertEqual( + compat._convert_request(request_with_json_body), + { + "method": "POST", + "url": "https://postman-echo.com/post", + "headers": { + "Content-Type": "application/json" + }, + "json": { + "k1": "v1", + "k2": "v2" + } + } + ) + + request_with_text_body = { + "method": "POST", + "url": "https://postman-echo.com/post", + "headers": { + "Content-Type": "text/plain" + }, + "body": "have a nice day" + } + self.assertEqual( + compat._convert_request(request_with_text_body), + { + "method": "POST", + "url": "https://postman-echo.com/post", + "headers": { + "Content-Type": "text/plain" + }, + "data": "have a nice day" + } + ) + def test_convert_jmespath(self): self.assertEqual(compat._convert_jmespath("content.abc"), "body.abc") self.assertEqual(compat._convert_jmespath("json.abc"), "body.abc") @@ -85,7 +132,7 @@ class TestCompat(unittest.TestCase): [{"eq": ["body[0].name", 201]}], ) - def test_ensure_testcase_v3_api(self): + def test_ensure_testcase_v4_api(self): api_content = { "name": "get with params", "request": { @@ -98,7 +145,7 @@ class TestCompat(unittest.TestCase): "validate": [{"eq": ["content.varB", 200]}, {"lt": ["json.0.varC", 0]}], } self.assertEqual( - compat.ensure_testcase_v3_api(api_content), + compat.ensure_testcase_v4_api(api_content), { "config": { "name": "get with params", @@ -126,7 +173,7 @@ class TestCompat(unittest.TestCase): }, ) - def test_ensure_testcase_v3(self): + def test_ensure_testcase_v4(self): testcase_content = { "config": {"name": "xxx", "base_url": "https://httpbin.org"}, "teststeps": [ @@ -150,7 +197,7 @@ class TestCompat(unittest.TestCase): ], } self.assertEqual( - compat.ensure_testcase_v3(testcase_content), + compat.ensure_testcase_v4(testcase_content), { "config": {"name": "xxx", "base_url": "https://httpbin.org"}, "teststeps": [ diff --git a/httprunner/make.py b/httprunner/make.py index 75a4e783..7fe473b3 100644 --- a/httprunner/make.py +++ b/httprunner/make.py @@ -11,8 +11,8 @@ from httprunner import __version__, exceptions from httprunner.compat import ( convert_variables, ensure_path_sep, - ensure_testcase_v3, - ensure_testcase_v3_api, + ensure_testcase_v4, + ensure_testcase_v4_api, ) from httprunner.loader import ( convert_relative_project_root_dir, @@ -332,8 +332,8 @@ def make_teststep_chain_style(teststep: Dict) -> Text: def make_testcase(testcase: Dict, dir_path: Text = None) -> Text: """convert valid testcase dict to pytest file path""" - # ensure compatibility with testcase format v2 - testcase = ensure_testcase_v3(testcase) + # ensure compatibility with testcase format v2/v3 + testcase = ensure_testcase_v4(testcase) # validate testcase format load_testcase(testcase) @@ -373,9 +373,9 @@ def make_testcase(testcase: Dict, dir_path: Text = None) -> Text: if not isinstance(test_content, Dict): raise exceptions.TestCaseFormatError(f"Invalid teststep: {teststep}") - # api in v2 format, convert to v3 testcase + # api in v2/v3 format, convert to v4 testcase if "request" in test_content and "name" in test_content: - test_content = ensure_testcase_v3_api(test_content) + test_content = ensure_testcase_v4_api(test_content) test_content.setdefault("config", {})["path"] = ref_testcase_path ref_testcase_python_abs_path = make_testcase(test_content) @@ -473,9 +473,9 @@ def __make(tests_path: Text): ) continue - # api in v2 format, convert to v3 testcase + # api in v2/v3 format, convert to v4 testcase if "request" in test_content and "name" in test_content: - test_content = ensure_testcase_v3_api(test_content) + test_content = ensure_testcase_v4_api(test_content) if "config" not in test_content: logger.warning( diff --git a/httprunner/runner.py b/httprunner/runner.py index 28b1022d..74347bee 100644 --- a/httprunner/runner.py +++ b/httprunner/runner.py @@ -226,6 +226,8 @@ class SessionRunner(object): self.__run_step(step) finally: logger.info(f"generate testcase log: {self.__log_path}") + if USE_ALLURE: + allure.attach.file(self.__log_path, name='all log', attachment_type=allure.attachment_type.TEXT) self.__duration = time.time() - self.__start_at return self diff --git a/httprunner/step.py b/httprunner/step.py index 3b5f2535..7f0485a5 100644 --- a/httprunner/step.py +++ b/httprunner/step.py @@ -64,4 +64,4 @@ class Step(object): return self.__step.type() def run(self, runner: HttpRunner) -> StepResult: - return self.__step.run(runner) \ No newline at end of file + return self.__step.run(runner) diff --git a/httprunner/step_request.py b/httprunner/step_request.py index 9299cba2..14b31034 100644 --- a/httprunner/step_request.py +++ b/httprunner/step_request.py @@ -17,7 +17,7 @@ from httprunner.models import ( ) from httprunner.parser import build_url from httprunner.response import ResponseObject -from httprunner.runner import HttpRunner +from httprunner.runner import HttpRunner, USE_ALLURE def call_hooks( @@ -99,8 +99,32 @@ def run_step_request(runner: HttpRunner, step: TStep) -> StepResult: parsed_request_dict["verify"] = config.verify parsed_request_dict["json"] = parsed_request_dict.pop("req_json", {}) + # log request + request_print = "====== request details ======\n" + request_print += f"url: {url}\n" + request_print += f"method: {method}\n" + headers = parsed_request_dict.get("headers", {}) + request_print += f"headers: {headers}\n" + for k, v in parsed_request_dict.items(): + v = utils.omit_long_data(v) + request_print += f"{k}: {repr(v)}\n" + request_print += "\n" + # request + if USE_ALLURE: + import allure + allure.attach(request_print, name="request details", attachment_type=allure.attachment_type.TEXT) resp = runner.session.request(method, url, **parsed_request_dict) + + # log response + response_print = "====== response details ======\n" + response_print += f"status_code: {resp.status_code}\n" + response_print += f"headers: {resp.headers}\n" + response_print += f"body: {repr(resp.text)}\n" + + if USE_ALLURE: + import allure + allure.attach(response_print, name="response details", attachment_type=allure.attachment_type.TEXT) resp_obj = ResponseObject(resp, runner.parser) step.variables["response"] = resp_obj @@ -110,24 +134,7 @@ def run_step_request(runner: HttpRunner, step: TStep) -> StepResult: def log_req_resp_details(): err_msg = "\n{} DETAILED REQUEST & RESPONSE {}\n".format("*" * 32, "*" * 32) - - # log request - err_msg += "====== request details ======\n" - err_msg += f"url: {url}\n" - err_msg += f"method: {method}\n" - headers = parsed_request_dict.pop("headers", {}) - err_msg += f"headers: {headers}\n" - for k, v in parsed_request_dict.items(): - v = utils.omit_long_data(v) - err_msg += f"{k}: {repr(v)}\n" - - err_msg += "\n" - - # log response - err_msg += "====== response details ======\n" - err_msg += f"status_code: {resp.status_code}\n" - err_msg += f"headers: {resp.headers}\n" - err_msg += f"body: {repr(resp.text)}\n" + err_msg += request_print + response_print logger.error(err_msg) # extract diff --git a/httprunner/step_sql_request.py b/httprunner/step_sql_request.py index cc5ddfba..6b396720 100644 --- a/httprunner/step_sql_request.py +++ b/httprunner/step_sql_request.py @@ -10,8 +10,12 @@ from httprunner.exceptions import ValidationFailure from httprunner.models import IStep, StepResult, TStep from httprunner.models import SqlMethodEnum, TSqlRequest from httprunner.response import SqlResponseObject -from httprunner.runner import HttpRunner -from httprunner.step_request import (StepRequestExtraction, StepRequestValidation, call_hooks) +from httprunner.runner import HttpRunner, USE_ALLURE +from httprunner.step_request import ( + StepRequestExtraction, + StepRequestValidation, + call_hooks, +) try: import sqlalchemy @@ -73,6 +77,7 @@ def run_step_sql_request(runner: HttpRunner, step: TStep) -> StepResult: if not runner.db_engine: ensure_sql_ready() from httprunner.database.engine import DBEngine + runner.db_engine = DBEngine( f'mysql+pymysql://{parsed_request_dict["db_config"]["user"]}:' f'{parsed_request_dict["db_config"]["password"]}@{parsed_request_dict["db_config"]["ip"]}:' @@ -89,6 +94,18 @@ def run_step_sql_request(runner: HttpRunner, step: TStep) -> StepResult: if step.setup_hooks: call_hooks(runner, step.setup_hooks, step.variables, "setup request") + # log request + sql_request_print = "====== sql request details ======\n" + sql_request_print += f"sql: {step.sql_request.sql}\n" + for k, v in parsed_request_dict.items(): + v = utils.omit_long_data(v) + sql_request_print += f"{k}: {repr(v)}\n" + + sql_request_print += "\n" + + if USE_ALLURE: + import allure + allure.attach(sql_request_print, name="sql request details", attachment_type=allure.attachment_type.TEXT) logger.info(f"Executing SQL: {parsed_request_dict['sql']}") if step.sql_request.method == SqlMethodEnum.FETCHONE: sql_resp = runner.db_engine.fetchone(parsed_request_dict["sql"]) @@ -108,6 +125,16 @@ def run_step_sql_request(runner: HttpRunner, step: TStep) -> StepResult: raise SqlMethodNotSupport( f"step.sql_request.method {parsed_request_dict['method']} not support" ) + # log response + sql_response_print = "====== sql response details ======\n" + for k, v in sql_resp.items(): + v = utils.omit_long_data(v) + sql_response_print += f"{k}: {repr(v)}\n" + + if USE_ALLURE: + import allure + allure.attach(sql_request_print, name="sql response details", attachment_type=allure.attachment_type.TEXT) + resp_obj = SqlResponseObject(sql_resp, parser=runner.parser) step.variables["sql_response"] = resp_obj @@ -117,21 +144,7 @@ def run_step_sql_request(runner: HttpRunner, step: TStep) -> StepResult: def log_sql_req_resp_details(): err_msg = "\n{} SQL DETAILED REQUEST & RESPONSE {}\n".format("*" * 32, "*" * 32) - - # log request - err_msg += "====== sql request details ======\n" - err_msg += f"sql: {step.sql_request.sql}\n" - for k, v in parsed_request_dict.items(): - v = utils.omit_long_data(v) - err_msg += f"{k}: {repr(v)}\n" - - err_msg += "\n" - - # log response - err_msg += "====== sql response details ======\n" - for k, v in sql_resp.items(): - v = utils.omit_long_data(v) - err_msg += f"{k}: {repr(v)}\n" + err_msg += sql_request_print + sql_response_print logger.error(err_msg) # extract diff --git a/httprunner/step_thrift_request.py b/httprunner/step_thrift_request.py index bef05448..5ba44681 100644 --- a/httprunner/step_thrift_request.py +++ b/httprunner/step_thrift_request.py @@ -3,7 +3,6 @@ import platform import sys import time from typing import Text, Union - from loguru import logger from httprunner import utils @@ -17,11 +16,11 @@ from httprunner.models import ( TransType, ) from httprunner.response import ThriftResponseObject -from httprunner.runner import HttpRunner +from httprunner.runner import HttpRunner, USE_ALLURE from httprunner.step_request import ( + call_hooks, StepRequestExtraction, StepRequestValidation, - call_hooks, ) try: @@ -66,30 +65,30 @@ def run_step_thrift_request(runner: HttpRunner, step: TStep) -> StepResult: parsed_request_dict["psm"] = parsed_request_dict["psm"] or config.thrift.psm parsed_request_dict["env"] = parsed_request_dict["env"] or config.thrift.env parsed_request_dict["cluster"] = ( - parsed_request_dict["cluster"] or config.thrift.cluster + parsed_request_dict["cluster"] or config.thrift.cluster ) parsed_request_dict["idl_path"] = ( - parsed_request_dict["idl_path"] or config.thrift.idl_path + parsed_request_dict["idl_path"] or config.thrift.idl_path ) parsed_request_dict["include_dirs"] = ( - parsed_request_dict["include_dirs"] or config.thrift.include_dirs + parsed_request_dict["include_dirs"] or config.thrift.include_dirs ) parsed_request_dict["method"] = ( - parsed_request_dict["method"] or config.thrift.method + parsed_request_dict["method"] or config.thrift.method ) parsed_request_dict["service_name"] = ( - parsed_request_dict["service_name"] or config.thrift.service_name + parsed_request_dict["service_name"] or config.thrift.service_name ) parsed_request_dict["ip"] = parsed_request_dict["ip"] or config.thrift.ip parsed_request_dict["port"] = parsed_request_dict["port"] or config.thrift.port parsed_request_dict["proto_type"] = ( - parsed_request_dict["proto_type"] or config.thrift.proto_type + parsed_request_dict["proto_type"] or config.thrift.proto_type ) parsed_request_dict["trans_port"] = ( - parsed_request_dict["trans_type"] or config.thrift.trans_type + parsed_request_dict["trans_type"] or config.thrift.trans_type ) parsed_request_dict["timeout"] = ( - parsed_request_dict["timeout"] or config.thrift.timeout + parsed_request_dict["timeout"] or config.thrift.timeout ) parsed_request_dict["thrift_client"] = parsed_request_dict["thrift_client"] @@ -105,6 +104,7 @@ def run_step_thrift_request(runner: HttpRunner, step: TStep) -> StepResult: if not runner.thrift_client: ensure_thrift_ready() from httprunner.thrift.thrift_client import ThriftClient + runner.thrift_client = ThriftClient( thrift_file=parsed_request_dict["idl_path"], service_name=parsed_request_dict["service_name"], @@ -120,6 +120,17 @@ def run_step_thrift_request(runner: HttpRunner, step: TStep) -> StepResult: if step.setup_hooks: call_hooks(runner, step.setup_hooks, step.variables, "setup request") + # log request + thrift_request_print = "====== thrift request details ======\n" + thrift_request_print += f"psm: {psm}\n" + for k, v in parsed_request_dict.items(): + v = utils.omit_long_data(v) + thrift_request_print += f"{k}: {repr(v)}\n" + thrift_request_print += "\n" + if USE_ALLURE: + import allure + allure.attach(thrift_request_print, name="thrift request details", attachment_type=allure.attachment_type.TEXT) + # thrift request resp = runner.thrift_client.send_request( parsed_request_dict["params"], parsed_request_dict["method"] @@ -127,6 +138,15 @@ def run_step_thrift_request(runner: HttpRunner, step: TStep) -> StepResult: resp_obj = ThriftResponseObject(resp, parser=runner.parser) step.variables["thrift_response"] = resp_obj + # log response + thrift_response_print = "====== thrift response details ======\n" + for k, v in resp.items(): + v = utils.omit_long_data(v) + thrift_response_print += f"{k}: {repr(v)}\n" + if USE_ALLURE: + import allure + allure.attach(thrift_request_print, name="thrift response details", attachment_type=allure.attachment_type.TEXT) + # teardown hooks if step.teardown_hooks: call_hooks(runner, step.teardown_hooks, step.variables, "teardown request") @@ -135,21 +155,7 @@ def run_step_thrift_request(runner: HttpRunner, step: TStep) -> StepResult: err_msg = "\n{} THRIFT DETAILED REQUEST & RESPONSE {}\n".format( "*" * 32, "*" * 32 ) - - # log request - err_msg += "====== thrift request details ======\n" - err_msg += f"psm: {psm}\n" - for k, v in parsed_request_dict.items(): - v = utils.omit_long_data(v) - err_msg += f"{k}: {repr(v)}\n" - - err_msg += "\n" - - # log response - err_msg += "====== thrift response details ======\n" - for k, v in resp.items(): - v = utils.omit_long_data(v) - err_msg += f"{k}: {repr(v)}\n" + err_msg += thrift_request_print + thrift_response_print logger.error(err_msg) # extract @@ -214,7 +220,7 @@ class RunThriftRequest(IStep): return self def teardown_hook( - self, hook: Text, assign_var_name: Text = None + self, hook: Text, assign_var_name: Text = None ) -> "RunThriftRequest": if assign_var_name: self.__step.teardown_hooks.append({assign_var_name: hook}) @@ -224,7 +230,7 @@ class RunThriftRequest(IStep): return self def setup_hook( - self, hook: Text, assign_var_name: Text = None + self, hook: Text, assign_var_name: Text = None ) -> "RunThriftRequest": if assign_var_name: self.__step.setup_hooks.append({assign_var_name: hook}) @@ -247,7 +253,7 @@ class RunThriftRequest(IStep): return self def with_thrift_client( - self, thrift_client: Union["ThriftClient", str] + self, thrift_client: Union["ThriftClient", str] ) -> "RunThriftRequest": self.__step.thrift_request.thrift_client = thrift_client return self @@ -287,7 +293,7 @@ class RunThriftRequest(IStep): return StepThriftRequestValidation(self.__step) def with_jmespath( - self, jmes_path: Text, var_name: Text + self, jmes_path: Text, var_name: Text ) -> "StepThriftRequestExtraction": self.__step.extract[var_name] = jmes_path return StepThriftRequestExtraction(self.__step) diff --git a/httprunner/thrift/data_convertor.py b/httprunner/thrift/data_convertor.py index b25af390..0561ef4a 100644 --- a/httprunner/thrift/data_convertor.py +++ b/httprunner/thrift/data_convertor.py @@ -307,7 +307,7 @@ class MyJSONEncoder(json.JSONEncoder): chunks = self.iterencode(o, _one_shot=True) if not isinstance(chunks, (list, tuple)): chunks = list(chunks) - # add by braver(braver@bytedance.com) + # add by braver # todo: fix 'utf8' codec can't decode byte 0x91 in position 3: invalid start byte" if self.skip_nonutf8_value: # 缺省为false tmp_chunks = [] @@ -324,7 +324,7 @@ class MyJSONEncoder(json.JSONEncoder): class ThriftJSONEncoder(json.JSONEncoder): """ - add by braver(Braver@bytedance.com) + add by braver """ def __init__( @@ -377,7 +377,7 @@ class ThriftJSONEncoder(json.JSONEncoder): chunks = self.iterencode(o, _one_shot=True) if not isinstance(chunks, (list, tuple)): chunks = list(chunks) - # add by braver(braver@bytedance.com) + # add by braver # todo: fix 'utf8' codec can't decode byte 0x91 in position 3: invalid start byte" if self.skip_nonutf8_value: # 缺省为false tmp_chunks = [] diff --git a/poetry.lock b/poetry.lock index 7482ae2c..bf7305fb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -138,11 +138,11 @@ reference = "tsinghua" [[package]] name = "click" -version = "8.0.4" +version = "8.1.3" description = "Composable command line interface toolkit" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} @@ -184,7 +184,7 @@ name = "cython" version = "0.29.28" description = "The Cython compiler for writing C extensions for the Python language." category = "main" -optional = false +optional = true python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [package.source] @@ -194,7 +194,7 @@ reference = "tsinghua" [[package]] name = "filetype" -version = "1.0.10" +version = "1.0.13" description = "Infer file type and MIME type of any file/buffer. No external dependencies." category = "main" optional = true @@ -210,7 +210,7 @@ name = "greenlet" version = "1.1.2" description = "Lightweight in-process concurrent programming" category = "main" -optional = false +optional = true python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" [package.extras] @@ -271,7 +271,7 @@ reference = "tsinghua" [[package]] name = "jinja2" -version = "3.1.1" +version = "3.1.2" description = "A very fast and expressive template engine." category = "main" optional = false @@ -418,7 +418,7 @@ name = "ply" version = "3.11" description = "Python Lex & Yacc" category = "main" -optional = false +optional = true python-versions = "*" [package.source] @@ -478,14 +478,14 @@ reference = "tsinghua" [[package]] name = "pyparsing" -version = "3.0.7" -description = "Python parsing module" +version = "3.0.8" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.6.8" [package.extras] -diagrams = ["jinja2", "railroad-diagrams"] +diagrams = ["railroad-diagrams", "jinja2"] [package.source] type = "legacy" @@ -494,7 +494,7 @@ reference = "tsinghua" [[package]] name = "pytest" -version = "7.1.1" +version = "7.1.2" description = "pytest: simple powerful testing with Python" category = "main" optional = false @@ -640,7 +640,7 @@ name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" category = "main" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [package.source] @@ -653,7 +653,7 @@ name = "sqlalchemy" version = "1.4.36" description = "Database Abstraction Library" category = "main" -optional = false +optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" [package.dependencies] @@ -691,7 +691,7 @@ name = "thrift" version = "0.16.0" description = "Python bindings for the Apache Thrift RPC system" category = "main" -optional = false +optional = true python-versions = "*" [package.dependencies] @@ -712,7 +712,7 @@ name = "thriftpy2" version = "0.4.14" description = "Pure python implementation of Apache Thrift." category = "main" -optional = false +optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] @@ -755,7 +755,7 @@ reference = "tsinghua" [[package]] name = "typed-ast" -version = "1.5.2" +version = "1.5.3" description = "a fork of Python 2 and 3 ast modules with type comment support" category = "main" optional = false @@ -768,11 +768,11 @@ reference = "tsinghua" [[package]] name = "typing-extensions" -version = "4.1.1" -description = "Backported and Experimental Type Hints for Python 3.6+" +version = "4.2.0" +description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.source] type = "legacy" @@ -815,15 +815,15 @@ reference = "tsinghua" [[package]] name = "zipp" -version = "3.7.0" +version = "3.8.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false python-versions = ">=3.7" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] [package.source] type = "legacy" @@ -839,7 +839,7 @@ upload = ["requests-toolbelt", "filetype"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "a00de4a66e9c8b73709f339d266be673ca6057dfd4023504677054697611986d" +content-hash = "325219fc54f9d7133867db25ccabbb311459eeea519dc5a5a4a48290b80187d0" [metadata.files] allure-pytest = [ @@ -895,9 +895,6 @@ brotli = [ {file = "Brotli-1.0.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ee83d3e3a024a9618e5be64648d6d11c37047ac48adff25f12fa4226cf23d1c"}, {file = "Brotli-1.0.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:19598ecddd8a212aedb1ffa15763dd52a388518c4550e615aed88dc3753c0f0c"}, {file = "Brotli-1.0.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:44bb8ff420c1d19d91d79d8c3574b8954288bdff0273bf788954064d260d7ab0"}, - {file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e23281b9a08ec338469268f98f194658abfb13658ee98e2b7f85ee9dd06caa91"}, - {file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3496fc835370da351d37cada4cf744039616a6db7d13c430035e901443a34daa"}, - {file = "Brotli-1.0.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83bb06a0192cccf1eb8d0a28672a1b79c74c3a8a5f2619625aeb6f28b3a82bb"}, {file = "Brotli-1.0.9-cp310-cp310-win32.whl", hash = "sha256:26d168aac4aaec9a4394221240e8a5436b5634adc3cd1cdf637f6645cecbf181"}, {file = "Brotli-1.0.9-cp310-cp310-win_amd64.whl", hash = "sha256:622a231b08899c864eb87e85f81c75e7b9ce05b001e59bbfbf43d4a71f5f32b2"}, {file = "Brotli-1.0.9-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4"}, @@ -909,18 +906,12 @@ brotli = [ {file = "Brotli-1.0.9-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4"}, {file = "Brotli-1.0.9-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a"}, {file = "Brotli-1.0.9-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87fdccbb6bb589095f413b1e05734ba492c962b4a45a13ff3408fa44ffe6479b"}, - {file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:6d847b14f7ea89f6ad3c9e3901d1bc4835f6b390a9c71df999b0162d9bb1e20f"}, - {file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:495ba7e49c2db22b046a53b469bbecea802efce200dffb69b93dd47397edc9b6"}, - {file = "Brotli-1.0.9-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:4688c1e42968ba52e57d8670ad2306fe92e0169c6f3af0089be75bbac0c64a3b"}, {file = "Brotli-1.0.9-cp36-cp36m-win32.whl", hash = "sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14"}, {file = "Brotli-1.0.9-cp36-cp36m-win_amd64.whl", hash = "sha256:1c48472a6ba3b113452355b9af0a60da5c2ae60477f8feda8346f8fd48e3e87c"}, {file = "Brotli-1.0.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3b78a24b5fd13c03ee2b7b86290ed20efdc95da75a3557cc06811764d5ad1126"}, {file = "Brotli-1.0.9-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:9d12cf2851759b8de8ca5fde36a59c08210a97ffca0eb94c532ce7b17c6a3d1d"}, {file = "Brotli-1.0.9-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12"}, {file = "Brotli-1.0.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29d1d350178e5225397e28ea1b7aca3648fcbab546d20e7475805437bfb0a130"}, - {file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7bbff90b63328013e1e8cb50650ae0b9bac54ffb4be6104378490193cd60f85a"}, - {file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ec1947eabbaf8e0531e8e899fc1d9876c179fc518989461f5d24e2223395a9e3"}, - {file = "Brotli-1.0.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12effe280b8ebfd389022aa65114e30407540ccb89b177d3fbc9a4f177c4bd5d"}, {file = "Brotli-1.0.9-cp37-cp37m-win32.whl", hash = "sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1"}, {file = "Brotli-1.0.9-cp37-cp37m-win_amd64.whl", hash = "sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5"}, {file = "Brotli-1.0.9-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e16eb9541f3dd1a3e92b89005e37b1257b157b7256df0e36bd7b33b50be73bcb"}, @@ -928,9 +919,6 @@ brotli = [ {file = "Brotli-1.0.9-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb"}, {file = "Brotli-1.0.9-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26"}, {file = "Brotli-1.0.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a674ac10e0a87b683f4fa2b6fa41090edfd686a6524bd8dedbd6138b309175c"}, - {file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e2d9e1cbc1b25e22000328702b014227737756f4b5bf5c485ac1d8091ada078b"}, - {file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b336c5e9cf03c7be40c47b5fd694c43c9f1358a80ba384a21969e0b4e66a9b17"}, - {file = "Brotli-1.0.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:85f7912459c67eaab2fb854ed2bc1cc25772b300545fe7ed2dc03954da638649"}, {file = "Brotli-1.0.9-cp38-cp38-win32.whl", hash = "sha256:35a3edbe18e876e596553c4007a087f8bcfd538f19bc116917b3c7522fca0429"}, {file = "Brotli-1.0.9-cp38-cp38-win_amd64.whl", hash = "sha256:269a5743a393c65db46a7bb982644c67ecba4b8d91b392403ad8a861ba6f495f"}, {file = "Brotli-1.0.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2aad0e0baa04517741c9bb5b07586c642302e5fb3e75319cb62087bd0995ab19"}, @@ -938,9 +926,6 @@ brotli = [ {file = "Brotli-1.0.9-cp39-cp39-manylinux1_i686.whl", hash = "sha256:16d528a45c2e1909c2798f27f7bf0a3feec1dc9e50948e738b961618e38b6a7b"}, {file = "Brotli-1.0.9-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:56d027eace784738457437df7331965473f2c0da2c70e1a1f6fdbae5402e0389"}, {file = "Brotli-1.0.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bf919756d25e4114ace16a8ce91eb340eb57a08e2c6950c3cebcbe3dff2a5e7"}, - {file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e4c4e92c14a57c9bd4cb4be678c25369bf7a092d55fd0866f759e425b9660806"}, - {file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e48f4234f2469ed012a98f4b7874e7f7e173c167bed4934912a29e03167cf6b1"}, - {file = "Brotli-1.0.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9ed4c92a0665002ff8ea852353aeb60d9141eb04109e88928026d3c8a9e5433c"}, {file = "Brotli-1.0.9-cp39-cp39-win32.whl", hash = "sha256:cfc391f4429ee0a9370aa93d812a52e1fee0f37a81861f4fdd1f4fb28e8547c3"}, {file = "Brotli-1.0.9-cp39-cp39-win_amd64.whl", hash = "sha256:854c33dad5ba0fbd6ab69185fec8dab89e13cda6b7d191ba111987df74f38761"}, {file = "Brotli-1.0.9-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9749a124280a0ada4187a6cfd1ffd35c350fb3af79c706589d98e088c5044267"}, @@ -956,8 +941,8 @@ charset-normalizer = [ {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, ] click = [ - {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, - {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -1036,8 +1021,8 @@ cython = [ {file = "Cython-0.29.28.tar.gz", hash = "sha256:d6fac2342802c30e51426828fe084ff4deb1b3387367cf98976bb2e64b6f8e45"}, ] filetype = [ - {file = "filetype-1.0.10-py2.py3-none-any.whl", hash = "sha256:63fbe6e818a3d1cfac1d62b196574a7a4b7fc8e06a6c500d53577c018ef127d9"}, - {file = "filetype-1.0.10.tar.gz", hash = "sha256:323a13500731b6c65a253bc3930bbce9a56dfba71e90b60ffd968ab69d9ae937"}, + {file = "filetype-1.0.13-py2.py3-none-any.whl", hash = "sha256:8f5d2d5ae7fec00c71dadfe8a747c2d6da91d77f9b4e550bbdb0881f63a07e43"}, + {file = "filetype-1.0.13.tar.gz", hash = "sha256:6a104762fe93d755c962aa96cb3d930a48f91a0761047126c5eead2153e33b03"}, ] greenlet = [ {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"}, @@ -1109,8 +1094,8 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ - {file = "Jinja2-3.1.1-py3-none-any.whl", hash = "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119"}, - {file = "Jinja2-3.1.1.tar.gz", hash = "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"}, + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, ] jmespath = [ {file = "jmespath-0.9.5-py2.py3-none-any.whl", hash = "sha256:695cb76fa78a10663425d5b73ddc5714eb711157e52704d69be03b1a02ba4fec"}, @@ -1219,12 +1204,12 @@ pymysql = [ {file = "PyMySQL-1.0.2.tar.gz", hash = "sha256:816927a350f38d56072aeca5dfb10221fe1dc653745853d30a216637f5d7ad36"}, ] pyparsing = [ - {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, - {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, + {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, + {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, ] pytest = [ - {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, - {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, + {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, + {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, ] pytest-html = [ {file = "pytest-html-3.1.1.tar.gz", hash = "sha256:3ee1cf319c913d19fe53aeb0bc400e7b0bc2dbeb477553733db1dad12eb75ee3"}, @@ -1335,34 +1320,34 @@ tomli = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] typed-ast = [ - {file = "typed_ast-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266"}, - {file = "typed_ast-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:676d051b1da67a852c0447621fdd11c4e104827417bf216092ec3e286f7da596"}, - {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc2542e83ac8399752bc16e0b35e038bdb659ba237f4222616b4e83fb9654985"}, - {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74cac86cc586db8dfda0ce65d8bcd2bf17b58668dfcc3652762f3ef0e6677e76"}, - {file = "typed_ast-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:18fe320f354d6f9ad3147859b6e16649a0781425268c4dde596093177660e71a"}, - {file = "typed_ast-1.5.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:31d8c6b2df19a777bc8826770b872a45a1f30cfefcfd729491baa5237faae837"}, - {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:963a0ccc9a4188524e6e6d39b12c9ca24cc2d45a71cfdd04a26d883c922b4b78"}, - {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb77764ea470f14fcbb89d51bc6bbf5e7623446ac4ed06cbd9ca9495b62e36e"}, - {file = "typed_ast-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:294a6903a4d087db805a7656989f613371915fc45c8cc0ddc5c5a0a8ad9bea4d"}, - {file = "typed_ast-1.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26a432dc219c6b6f38be20a958cbe1abffcc5492821d7e27f08606ef99e0dffd"}, - {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7407cfcad702f0b6c0e0f3e7ab876cd1d2c13b14ce770e412c0c4b9728a0f88"}, - {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f30ddd110634c2d7534b2d4e0e22967e88366b0d356b24de87419cc4410c41b7"}, - {file = "typed_ast-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8c08d6625bb258179b6e512f55ad20f9dfef019bbfbe3095247401e053a3ea30"}, - {file = "typed_ast-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90904d889ab8e81a956f2c0935a523cc4e077c7847a836abee832f868d5c26a4"}, - {file = "typed_ast-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bbebc31bf11762b63bf61aaae232becb41c5bf6b3461b80a4df7e791fabb3aca"}, - {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29dd9a3a9d259c9fa19d19738d021632d673f6ed9b35a739f48e5f807f264fb"}, - {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:58ae097a325e9bb7a684572d20eb3e1809802c5c9ec7108e85da1eb6c1a3331b"}, - {file = "typed_ast-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:da0a98d458010bf4fe535f2d1e367a2e2060e105978873c04c04212fb20543f7"}, - {file = "typed_ast-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33b4a19ddc9fc551ebabca9765d54d04600c4a50eda13893dadf67ed81d9a098"}, - {file = "typed_ast-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1098df9a0592dd4c8c0ccfc2e98931278a6c6c53cb3a3e2cf7e9ee3b06153344"}, - {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c47c3b43fe3a39ddf8de1d40dbbfca60ac8530a36c9b198ea5b9efac75c09e"}, - {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f290617f74a610849bd8f5514e34ae3d09eafd521dceaa6cf68b3f4414266d4e"}, - {file = "typed_ast-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:df05aa5b241e2e8045f5f4367a9f6187b09c4cdf8578bb219861c4e27c443db5"}, - {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"}, + {file = "typed_ast-1.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ad3b48cf2b487be140072fb86feff36801487d4abb7382bb1929aaac80638ea"}, + {file = "typed_ast-1.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:542cd732351ba8235f20faa0fc7398946fe1a57f2cdb289e5497e1e7f48cfedb"}, + {file = "typed_ast-1.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc2c11ae59003d4a26dda637222d9ae924387f96acae9492df663843aefad55"}, + {file = "typed_ast-1.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd5df1313915dbd70eaaa88c19030b441742e8b05e6103c631c83b75e0435ccc"}, + {file = "typed_ast-1.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:e34f9b9e61333ecb0f7d79c21c28aa5cd63bec15cb7e1310d7d3da6ce886bc9b"}, + {file = "typed_ast-1.5.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f818c5b81966d4728fec14caa338e30a70dfc3da577984d38f97816c4b3071ec"}, + {file = "typed_ast-1.5.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3042bfc9ca118712c9809201f55355479cfcdc17449f9f8db5e744e9625c6805"}, + {file = "typed_ast-1.5.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4fff9fdcce59dc61ec1b317bdb319f8f4e6b69ebbe61193ae0a60c5f9333dc49"}, + {file = "typed_ast-1.5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8e0b8528838ffd426fea8d18bde4c73bcb4167218998cc8b9ee0a0f2bfe678a6"}, + {file = "typed_ast-1.5.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ef1d96ad05a291f5c36895d86d1375c0ee70595b90f6bb5f5fdbee749b146db"}, + {file = "typed_ast-1.5.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed44e81517364cb5ba367e4f68fca01fba42a7a4690d40c07886586ac267d9b9"}, + {file = "typed_ast-1.5.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f60d9de0d087454c91b3999a296d0c4558c1666771e3460621875021bf899af9"}, + {file = "typed_ast-1.5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9e237e74fd321a55c90eee9bc5d44be976979ad38a29bbd734148295c1ce7617"}, + {file = "typed_ast-1.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ee852185964744987609b40aee1d2eb81502ae63ee8eef614558f96a56c1902d"}, + {file = "typed_ast-1.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:27e46cdd01d6c3a0dd8f728b6a938a6751f7bd324817501c15fb056307f918c6"}, + {file = "typed_ast-1.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d64dabc6336ddc10373922a146fa2256043b3b43e61f28961caec2a5207c56d5"}, + {file = "typed_ast-1.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8cdf91b0c466a6c43f36c1964772918a2c04cfa83df8001ff32a89e357f8eb06"}, + {file = "typed_ast-1.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:9cc9e1457e1feb06b075c8ef8aeb046a28ec351b1958b42c7c31c989c841403a"}, + {file = "typed_ast-1.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e20d196815eeffb3d76b75223e8ffed124e65ee62097e4e73afb5fec6b993e7a"}, + {file = "typed_ast-1.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:37e5349d1d5de2f4763d534ccb26809d1c24b180a477659a12c4bde9dd677d74"}, + {file = "typed_ast-1.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9f1a27592fac87daa4e3f16538713d705599b0a27dfe25518b80b6b017f0a6d"}, + {file = "typed_ast-1.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8831479695eadc8b5ffed06fdfb3e424adc37962a75925668deeb503f446c0a3"}, + {file = "typed_ast-1.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:20d5118e494478ef2d3a2702d964dae830aedd7b4d3b626d003eea526be18718"}, + {file = "typed_ast-1.5.3.tar.gz", hash = "sha256:27f25232e2dd0edfe1f019d6bfaaf11e86e657d9bdb7b0956db95f560cceb2b3"}, ] typing-extensions = [ - {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, - {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, + {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, + {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, ] urllib3 = [ {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, @@ -1373,6 +1358,6 @@ win32-setctime = [ {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, ] zipp = [ - {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, - {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, + {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, + {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, ] diff --git a/pyproject.toml b/pyproject.toml index 9d6ea531..b1a8da31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "httprunner" -version = "v4.0.0" +version = "v4.1.2" description = "One-stop solution for HTTP(S) testing." license = "Apache-2.0" readme = "README.md" diff --git a/scripts/install-pre-commit-hook b/scripts/install-pre-commit-hook new file mode 100644 index 00000000..6c47fa98 --- /dev/null +++ b/scripts/install-pre-commit-hook @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +echo "SCRIPT_DIR:, $SCRIPT_DIR" +# assume the script is always in /scripts +pushd "$SCRIPT_DIR/.." >/dev/null + +PRE_COMMIT_FILE=.git/hooks/pre-commit + +# install pre-commit hook and make it executable +function install() { + go get mvdan.cc/gofumpt + go get github.com/incu6us/goimports-reviser/v2@latest + cat > $PRE_COMMIT_FILE <<'EOF' +#!/bin/bash + +# What does this script do? +# 1. gofumpt go files automatically +# 2. goimports-reviser go files automatically +# 3. black python files automatically + +# make sure gofumpt is installed +# What does each letter mean in "ACMRTUXB"? +# Added (A), Copied (C), Deleted (D), Modified (M), Renamed (R), have their type (i.e. regular file, symlink, +# submodule, ...) changed (T), are Unmerged (U), are Unknown (X), or have had their pairing Broken (B) +for file in $(git diff --name-only --cached --diff-filter=ACMRTUXB | grep '.go$') +do + echo "(gofumpt) $file" + gofumpt -w "$file" + echo "(goimports-reviser) $file" + goimports-reviser -file-path "$file" -rm-unused + git add "$file" +done + +for file in $(git diff --name-only --cached --diff-filter=ACMRTUXB | grep '.py$') +do + echo "(black) $file" + black "$file" + git add "$file" +done +EOF + + chmod +x $PRE_COMMIT_FILE +} + +if [[ -f $PRE_COMMIT_FILE ]]; then + echo "Backing up $PRE_COMMIT_FILE to ${PRE_COMMIT_FILE}.bak" + mv $PRE_COMMIT_FILE ${PRE_COMMIT_FILE}.bak + install +else + install +fi + +popd >/dev/null \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh index c16f1c05..845a669b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -36,26 +36,6 @@ function get_arch() { echo "$arch" } -function get_pkg_suffix() { - os=$1 - if [ "$os" == "windows" ]; then - echo ".zip" - else - echo ".tar.gz" - fi -} - -function extract_pkg() { - pkg=$1 - if [[ $pkg == *.zip ]]; then # windows - echo "$ unzip -o $pkg -d ." - unzip -o $pkg -d . - else - echo "$ tar -xzf $pkg" - tar -xzf "$pkg" - fi -} - function main() { echoInfo "Detect target hrp package..." version=$(get_latest_version) @@ -64,24 +44,37 @@ function main() { echo "$version" exit 1 fi + echo "Latest version: $version" os=$(get_os) echo "Current OS: $os" - arch=$(get_arch) - echo "Current ARCH: $arch" - pkg_suffix=$(get_pkg_suffix $os) - pkg="hrp-$version-$os-$arch$pkg_suffix" - - # download from aliyun OSS - url="https://httprunner.oss-cn-beijing.aliyuncs.com/$pkg" - if ! curl --output /dev/null --silent --head --fail "$url"; then - # aliyun OSS url is invalid, try to download from github - version=$(get_latest_version) - pkg="hrp-$version-$os-$arch$pkg_suffix" - url="https://github.com/httprunner/httprunner/releases/download/$version/$pkg" + if [[ $os == mingw* ]]; then + echoWarn "Current OS is MinGW, try to use windows package" + os="windows" fi - echo "Latest version: $version" + arch=$(get_arch) + echo "Current ARCH: $arch" + pkg_suffix=".tar.gz" + pkg="hrp-$version-$os-$arch$pkg_suffix" + echo "Download package: $pkg" + + # download from aliyun OSS or github packages + aliyun_oss_url="https://httprunner.oss-cn-beijing.aliyuncs.com/$pkg" + github_url="https://github.com/httprunner/httprunner/releases/download/$version/$pkg" + valid_flag=false + for url in "$aliyun_oss_url" "$github_url"; do + if curl --output /dev/null --silent --head --fail "$url"; then + valid_flag=true + break + fi + echoWarn "Invalid download url: $url" + done + + if [[ "$valid_flag" == false ]]; then + echoError "No available download url found, exit!" + exit 1 + fi echo "Download url: $url" echo @@ -98,7 +91,13 @@ function main() { echo echoInfo "Extracting..." - extract_pkg "$pkg" + if [[ $os == windows ]]; then # windows + echo "$ unzip -o $pkg -d ." + unzip -o $pkg -d . + else + echo "$ tar -xzf $pkg" + tar -xzf "$pkg" + fi echo "$ ls -lh" ls -lh echo