diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2d00f359..a50a37a9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ - fix #1308: load `.env` file as environment variables - fix #1309: locate plugin file upward recursively until system root dir - refactor: move base_url to config env +- feat: support converting Postman collection to HttpRunner testcase +- refactor: improve the extensibility of `hrp convert` using interface `ICaseConverter` ## v4.1.0-beta (2022-05-21) diff --git a/docs/cmd/hrp.md b/docs/cmd/hrp.md index 5e74e839..c9499319 100644 --- a/docs/cmd/hrp.md +++ b/docs/cmd/hrp.md @@ -30,11 +30,11 @@ 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 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 25-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_boom.md b/docs/cmd/hrp_boom.md index f6eb3a55..429a0ed3 100644 --- a/docs/cmd/hrp_boom.md +++ b/docs/cmd/hrp_boom.md @@ -42,4 +42,4 @@ hrp boom [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_convert.md b/docs/cmd/hrp_convert.md index d778b691..3083456c 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 25-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_har2case.md b/docs/cmd/hrp_har2case.md index a37bbf1e..592c5281 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 25-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_pytest.md b/docs/cmd/hrp_pytest.md index 30682a20..711c8bac 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 25-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_run.md b/docs/cmd/hrp_run.md index 46725b8a..63da347e 100644 --- a/docs/cmd/hrp_run.md +++ b/docs/cmd/hrp_run.md @@ -35,4 +35,4 @@ hrp run $path... [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_startproject.md b/docs/cmd/hrp_startproject.md index eb0b6bb9..e55c5429 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 25-May-2022 +###### Auto generated by spf13/cobra on 27-May-2022 diff --git a/docs/cmd/hrp_wiki.md b/docs/cmd/hrp_wiki.md index 1219555f..2eecbdd0 100644 --- a/docs/cmd/hrp_wiki.md +++ b/docs/cmd/hrp_wiki.md @@ -16,4 +16,4 @@ hrp wiki [flags] * [hrp](hrp.md) - Next-Generation API Testing Solution. -###### Auto generated by spf13/cobra on 25-May-2022 +###### Auto generated by spf13/cobra on 27-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-with-go-plugin/proj.json b/examples/demo-with-go-plugin/proj.json index 4dea84bc..2b2fcb6b 100644 --- a/examples/demo-with-go-plugin/proj.json +++ b/examples/demo-with-go-plugin/proj.json @@ -1,6 +1,6 @@ { "project_name": "demo-with-go-plugin", - "project_path": "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/examples/demo-with-go-plugin", - "create_time": "2022-05-25T11:14:42.750876+08:00", + "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-with-go-plugin", + "create_time": "2022-05-27T11:34:23.903959+08:00", "hrp_version": "v4.1.0-beta" } \ No newline at end of file diff --git a/examples/demo-with-go-plugin/testcases/ref_testcase.yml b/examples/demo-with-go-plugin/testcases/ref_testcase.yml index 6cf32323..0816481c 100644 --- a/examples/demo-with-go-plugin/testcases/ref_testcase.yml +++ b/examples/demo-with-go-plugin/testcases/ref_testcase.yml @@ -26,8 +26,8 @@ teststeps: headers: User-Agent: funplugin/${get_version()} 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 index 9b4214d7..162632b4 100644 --- a/examples/demo-with-go-plugin/testcases/requests.json +++ b/examples/demo-with-go-plugin/testcases/requests.json @@ -38,28 +38,28 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.args.foo1", - "debugtalk" - ] + "check": "body.args.foo1", + "assert": "equal", + "expect": "debugtalk", + "msg": "check body.args.foo1" }, { - "eq": [ - "body.args.sum_v", - "3" - ] + "check": "body.args.sum_v", + "assert": "equal", + "expect": "3", + "msg": "check body.args.sum_v" }, { - "eq": [ - "body.args.foo2", - "bar21" - ] + "check": "body.args.foo2", + "assert": "equal", + "expect": "bar21", + "msg": "check body.args.foo2" } ] }, @@ -76,20 +76,20 @@ "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 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.data", - "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32." - ] + "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" } ] }, @@ -105,32 +105,32 @@ "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 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.form.foo1", - "$expect_foo1" - ] + "check": "body.form.foo1", + "assert": "equal", + "expect": "$expect_foo1", + "msg": "check body.form.foo1" }, { - "eq": [ - "body.form.foo2", - "bar23" - ] + "check": "body.form.foo2", + "assert": "equal", + "expect": "bar23", + "msg": "check body.form.foo2" }, { - "eq": [ - "body.form.foo3", - "bar21" - ] + "check": "body.form.foo3", + "assert": "equal", + "expect": "bar21", + "msg": "check body.form.foo3" } ] } diff --git a/examples/demo-with-go-plugin/testcases/requests.yml b/examples/demo-with-go-plugin/testcases/requests.yml index bc9aa108..034dbefb 100644 --- a/examples/demo-with-go-plugin/testcases/requests.yml +++ b/examples/demo-with-go-plugin/testcases/requests.yml @@ -42,7 +42,7 @@ teststeps: 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."] @@ -56,7 +56,7 @@ teststeps: 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/proj.json b/examples/demo-with-py-plugin/proj.json index cca69211..555bccd7 100644 --- a/examples/demo-with-py-plugin/proj.json +++ b/examples/demo-with-py-plugin/proj.json @@ -1,6 +1,6 @@ { "project_name": "demo-with-py-plugin", - "project_path": "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/examples/demo-with-py-plugin", - "create_time": "2022-05-25T11:14:52.333942+08:00", + "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-with-py-plugin", + "create_time": "2022-05-27T11:34:31.852589+08:00", "hrp_version": "v4.1.0-beta" } \ No newline at end of file diff --git a/examples/demo-with-py-plugin/testcases/ref_testcase.yml b/examples/demo-with-py-plugin/testcases/ref_testcase.yml index 6cf32323..0816481c 100644 --- a/examples/demo-with-py-plugin/testcases/ref_testcase.yml +++ b/examples/demo-with-py-plugin/testcases/ref_testcase.yml @@ -26,8 +26,8 @@ teststeps: headers: User-Agent: funplugin/${get_version()} 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 index 9b4214d7..162632b4 100644 --- a/examples/demo-with-py-plugin/testcases/requests.json +++ b/examples/demo-with-py-plugin/testcases/requests.json @@ -38,28 +38,28 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.args.foo1", - "debugtalk" - ] + "check": "body.args.foo1", + "assert": "equal", + "expect": "debugtalk", + "msg": "check body.args.foo1" }, { - "eq": [ - "body.args.sum_v", - "3" - ] + "check": "body.args.sum_v", + "assert": "equal", + "expect": "3", + "msg": "check body.args.sum_v" }, { - "eq": [ - "body.args.foo2", - "bar21" - ] + "check": "body.args.foo2", + "assert": "equal", + "expect": "bar21", + "msg": "check body.args.foo2" } ] }, @@ -76,20 +76,20 @@ "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 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.data", - "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32." - ] + "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" } ] }, @@ -105,32 +105,32 @@ "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 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.form.foo1", - "$expect_foo1" - ] + "check": "body.form.foo1", + "assert": "equal", + "expect": "$expect_foo1", + "msg": "check body.form.foo1" }, { - "eq": [ - "body.form.foo2", - "bar23" - ] + "check": "body.form.foo2", + "assert": "equal", + "expect": "bar23", + "msg": "check body.form.foo2" }, { - "eq": [ - "body.form.foo3", - "bar21" - ] + "check": "body.form.foo3", + "assert": "equal", + "expect": "bar21", + "msg": "check body.form.foo3" } ] } diff --git a/examples/demo-with-py-plugin/testcases/requests.yml b/examples/demo-with-py-plugin/testcases/requests.yml index bc9aa108..034dbefb 100644 --- a/examples/demo-with-py-plugin/testcases/requests.yml +++ b/examples/demo-with-py-plugin/testcases/requests.yml @@ -42,7 +42,7 @@ teststeps: 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."] @@ -56,7 +56,7 @@ teststeps: 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/proj.json b/examples/demo-without-plugin/proj.json index 3fabec85..72c78cbf 100644 --- a/examples/demo-without-plugin/proj.json +++ b/examples/demo-without-plugin/proj.json @@ -1,6 +1,6 @@ { "project_name": "demo-without-plugin", - "project_path": "/Users/debugtalk/MyProjects/HttpRunner-dev/httprunner/examples/demo-without-plugin", - "create_time": "2022-05-25T11:14:53.862348+08:00", + "project_path": "/Users/xxxxx/go/src/github.com/httprunner/httprunner/examples/demo-without-plugin", + "create_time": "2022-05-27T11:34:32.548637+08:00", "hrp_version": "v4.1.0-beta" } \ No newline at end of file diff --git a/go.mod b/go.mod index 5f821352..857c9444 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( 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.6 diff --git a/go.sum b/go.sum index 3326932a..4651a6e7 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= @@ -131,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= @@ -262,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= @@ -288,16 +301,20 @@ 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= @@ -348,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= @@ -837,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= @@ -854,6 +874,7 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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= diff --git a/hrp/cmd/convert.go b/hrp/cmd/convert.go index 0247d147..a4c8d663 100644 --- a/hrp/cmd/convert.go +++ b/hrp/cmd/convert.go @@ -2,9 +2,7 @@ package cmd import ( "errors" - "os" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/httprunner/httprunner/v4/hrp/internal/convert" @@ -12,37 +10,52 @@ import ( 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 (except for auto-generated headers) and cookies") } diff --git a/hrp/cmd/har2case.go b/hrp/cmd/har2case.go index eecd40cc..7d6f1994 100644 --- a/hrp/cmd/har2case.go +++ b/hrp/cmd/har2case.go @@ -3,10 +3,9 @@ package cmd import ( "errors" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "github.com/httprunner/httprunner/v4/hrp/internal/har2case" + "github.com/httprunner/httprunner/v4/hrp/internal/convert" ) // har2caseCmd represents the har2case command @@ -19,54 +18,34 @@ var har2caseCmd = &cobra.Command{ 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) + var flagCount int + var har2caseOutputType convert.OutputType + if har2caseGenJSONFlag { + flagCount++ } - log.Info().Strs("output", outputFiles).Msg("convert testcase success") + if har2caseGenYAMLFlag { + flagCount++ + har2caseOutputType = convert.OutputTypeYAML + } + if flagCount > 1 { + return errors.New("please specify at most one conversion flag") + } + convert.Run(har2caseOutputType, har2caseOutputDir, har2caseProfilePath, args) return nil }, } var ( - genJSONFlag bool - genYAMLFlag bool - outputDir string - profilePath string + har2caseGenJSONFlag bool + har2caseGenYAMLFlag bool + har2caseOutputDir string + har2caseProfilePath 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") + har2caseCmd.Flags().BoolVarP(&har2caseGenJSONFlag, "to-json", "j", false, "convert to JSON format (default)") + har2caseCmd.Flags().BoolVarP(&har2caseGenYAMLFlag, "to-yaml", "y", false, "convert to YAML format") + har2caseCmd.Flags().StringVarP(&har2caseOutputDir, "output-dir", "d", "", "specify output directory, default to the same dir with har file") + har2caseCmd.Flags().StringVarP(&har2caseProfilePath, "profile", "p", "", "specify profile path to override headers and cookies") } diff --git a/hrp/cmd/scaffold.go b/hrp/cmd/scaffold.go index 93a8e4b8..c51281ed 100644 --- a/hrp/cmd/scaffold.go +++ b/hrp/cmd/scaffold.go @@ -24,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 @@ -43,6 +45,7 @@ var scaffoldCmd = &cobra.Command{ } var ( + empty bool ignorePlugin bool genPythonPlugin bool genGoPlugin bool @@ -55,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/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/utils.go b/hrp/internal/builtin/utils.go index 170c39d0..d4920084 100644 --- a/hrp/internal/builtin/utils.go +++ b/hrp/internal/builtin/utils.go @@ -285,7 +285,8 @@ func LoadFile(path string, structObj interface{}) (err error) { 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": @@ -383,3 +384,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..d35a9031 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 + 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 *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 78% rename from hrp/internal/har2case/core_test.go rename to hrp/internal/convert/converter_har_test.go index de2ee910..0d4daa11 100644 --- a/hrp/internal/har2case/core_test.go +++ b/hrp/internal/convert/converter_har_test.go @@ -1,4 +1,4 @@ -package har2case +package convert import ( "testing" @@ -9,13 +9,16 @@ import ( ) 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() } @@ -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,7 +259,7 @@ func TestMakeRequestDataParams(t *testing.T) { }, }, } - step, err := har.prepareTestStep(entry) + step, err := converterHAR.prepareTestStep(entry) if !assert.NoError(t, err) { t.Fatal() } @@ -277,7 +270,6 @@ func TestMakeRequestDataParams(t *testing.T) { } 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() } 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 ea58dd6e..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/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" -) - -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 25824855..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/v4/hrp" - "github.com/httprunner/httprunner/v4/hrp/internal/builtin" - "github.com/httprunner/httprunner/v4/hrp/internal/json" - "github.com/httprunner/httprunner/v4/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/scaffold/examples_test.go b/hrp/internal/scaffold/examples_test.go index 3ec85cb5..3bde77b0 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/empty-demo-without-plugin" + 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 3392d960..0941838d 100644 --- a/hrp/internal/scaffold/main.go +++ b/hrp/internal/scaffold/main.go @@ -20,6 +20,7 @@ import ( type PluginType string const ( + Empty PluginType = "empty" Ignore PluginType = "ignore" Py PluginType = "py" Go PluginType = "go" @@ -127,8 +128,17 @@ 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", "requests.json")) if err != nil { @@ -138,6 +148,7 @@ 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.json")) if err != nil { 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 6cf32323..0816481c 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_ref_testcase.yml @@ -26,8 +26,8 @@ teststeps: headers: User-Agent: funplugin/${get_version()} 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_requests.json b/hrp/internal/scaffold/templates/testcases/demo_requests.json index 9b4214d7..162632b4 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_requests.json +++ b/hrp/internal/scaffold/templates/testcases/demo_requests.json @@ -38,28 +38,28 @@ }, "validate": [ { - "eq": [ - "status_code", - 200 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.args.foo1", - "debugtalk" - ] + "check": "body.args.foo1", + "assert": "equal", + "expect": "debugtalk", + "msg": "check body.args.foo1" }, { - "eq": [ - "body.args.sum_v", - "3" - ] + "check": "body.args.sum_v", + "assert": "equal", + "expect": "3", + "msg": "check body.args.sum_v" }, { - "eq": [ - "body.args.foo2", - "bar21" - ] + "check": "body.args.foo2", + "assert": "equal", + "expect": "bar21", + "msg": "check body.args.foo2" } ] }, @@ -76,20 +76,20 @@ "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 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.data", - "This is expected to be sent back as part of response body: bar12-$expect_foo2-bar32." - ] + "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" } ] }, @@ -105,32 +105,32 @@ "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 - ] + "check": "status_code", + "assert": "equal", + "expect": 200, + "msg": "check status_code" }, { - "eq": [ - "body.form.foo1", - "$expect_foo1" - ] + "check": "body.form.foo1", + "assert": "equal", + "expect": "$expect_foo1", + "msg": "check body.form.foo1" }, { - "eq": [ - "body.form.foo2", - "bar23" - ] + "check": "body.form.foo2", + "assert": "equal", + "expect": "bar23", + "msg": "check body.form.foo2" }, { - "eq": [ - "body.form.foo3", - "bar21" - ] + "check": "body.form.foo3", + "assert": "equal", + "expect": "bar21", + "msg": "check body.form.foo3" } ] } diff --git a/hrp/internal/scaffold/templates/testcases/demo_requests.yml b/hrp/internal/scaffold/templates/testcases/demo_requests.yml index bc9aa108..034dbefb 100644 --- a/hrp/internal/scaffold/templates/testcases/demo_requests.yml +++ b/hrp/internal/scaffold/templates/testcases/demo_requests.yml @@ -42,7 +42,7 @@ teststeps: 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."] @@ -56,7 +56,7 @@ teststeps: 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/plugin.go b/hrp/plugin.go index 67a86cfe..f9465a90 100644 --- a/hrp/plugin.go +++ b/hrp/plugin.go @@ -17,6 +17,7 @@ const ( goPluginFile = "debugtalk.so" // built from go plugin hashicorpGoPluginFile = "debugtalk.bin" // built from hashicorp go plugin hashicorpPyPluginFile = "debugtalk.py" // used for hashicorp python plugin + projectInfoFile = "proj.json" // used for ensuring root project ) func initPlugin(path string, logOn bool) (plugin funplugin.IPlugin, pluginDir string, err error) { @@ -118,9 +119,15 @@ func GetProjectRootDirPath(path string) (rootDir string, err error) { 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/runner.go b/hrp/runner.go index ca4c9db6..640e7af3 100644 --- a/hrp/runner.go +++ b/hrp/runner.go @@ -179,12 +179,13 @@ 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") + break + } } } s.Time.Duration = time.Since(s.Time.StartAt).Seconds() diff --git a/hrp/testcase.go b/hrp/testcase.go index f8f8d222..4182cec7 100644 --- a/hrp/testcase.go +++ b/hrp/testcase.go @@ -64,7 +64,7 @@ func (path *TestCasePath) ToTestCase() (*TestCase, error) { return nil, errors.New("incorrect testcase file format, expected config in file") } - err = tc.makeCompat() + err = tc.MakeCompat() if err != nil { return nil, err } @@ -173,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 @@ -206,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 } 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(