From 5888c51386412616d81751736799cf2e8e520f08 Mon Sep 17 00:00:00 2001 From: debugtalk Date: Wed, 23 Mar 2022 10:36:24 +0800 Subject: [PATCH] refactor: move files to hrp --- .github/workflows/hrp-release.yml | 45 + .github/workflows/hrp-scaffold.yml | 73 + .../{integration_test.yml => smoketest.yml} | 6 +- .github/workflows/unittest.yml | 37 +- .gitignore | 49 +- Makefile | 25 + docs/cmd/hrp.md | 36 + docs/cmd/hrp_boom.md | 44 + docs/cmd/hrp_har2case.md | 26 + docs/cmd/hrp_run.md | 37 + docs/cmd/hrp_startproject.md | 22 + examples/hrp/__init__.py | 1 + examples/hrp/account.csv | 4 + examples/hrp/api/get.json | 34 + examples/hrp/api/get.yml | 22 + examples/hrp/api/post.json | 45 + examples/hrp/api/post.yml | 30 + examples/hrp/api/put.json | 45 + examples/hrp/api/put.yml | 30 + examples/hrp/compat_test.go | 25 + examples/hrp/debugtalk.py | 55 + examples/hrp/demo.json | 176 + examples/hrp/demo.yaml | 114 + examples/hrp/demo_httprunner.json | 135 + examples/hrp/demo_httprunner.yaml | 81 + examples/hrp/demo_test.py | 63 + examples/hrp/extract_test.go | 84 + examples/hrp/function_test.go | 49 + examples/hrp/har/demo.har | 356 ++ examples/hrp/har/demo.json | 128 + examples/hrp/har/postman-echo.har | 4694 +++++++++++++++++ examples/hrp/har/postman-echo.yaml | 1101 ++++ examples/hrp/httpbin.json | 51 + examples/hrp/parameters_test.json | 61 + examples/hrp/parameters_test.yaml | 40 + examples/hrp/plugin/debugtalk.go | 65 + examples/hrp/plugin/hashicorp.go | 18 + examples/hrp/postman-echo.json | 1578 ++++++ examples/hrp/postman-echo.yaml | 1101 ++++ examples/hrp/ref_api_test.json | 78 + examples/hrp/ref_api_test.yaml | 47 + examples/hrp/ref_testcase_test.json | 18 + examples/hrp/ref_testcase_test.yaml | 11 + examples/hrp/rendezvous_test.go | 56 + examples/hrp/rendezvous_test.json | 106 + examples/hrp/request_test.go | 74 + examples/hrp/think_time_test.json | 63 + examples/hrp/think_time_test.yaml | 40 + examples/hrp/validate_test.go | 55 + examples/hrp/variables_test.go | 144 + go.mod | 24 + go.sum | 737 +++ hrp/README.md | 316 ++ hrp/boomer.go | 177 + hrp/boomer_test.go | 34 + hrp/cmd/boom.go | 83 + hrp/cmd/doc_test.go | 15 + hrp/cmd/har2case.go | 64 + hrp/cmd/root.go | 80 + hrp/cmd/run.go | 68 + hrp/cmd/scaffold.go | 54 + hrp/convert.go | 256 + hrp/convert_test.go | 69 + hrp/docs/CHANGELOG.md | 142 + hrp/docs/README.md | 9 + hrp/docs/assets/flow.jpg | Bin 0 -> 219308 bytes hrp/docs/assets/hogwarts.jpeg | Bin 0 -> 76446 bytes hrp/docs/assets/qrcode.jpg | Bin 0 -> 8706 bytes hrp/docs/assets/sentry-logo-black.svg | 1 + hrp/docs/builtin.md | 53 + hrp/extract.go | 33 + hrp/internal/boomer/README.md | 5 + hrp/internal/boomer/boomer.go | 171 + hrp/internal/boomer/boomer_test.go | 146 + hrp/internal/boomer/output.go | 532 ++ hrp/internal/boomer/output_test.go | 104 + hrp/internal/boomer/ratelimiter.go | 230 + hrp/internal/boomer/ratelimiter_test.go | 102 + hrp/internal/boomer/runner.go | 372 ++ hrp/internal/boomer/runner_test.go | 116 + hrp/internal/boomer/stats.go | 316 ++ hrp/internal/boomer/stats_test.go | 216 + hrp/internal/boomer/task.go | 13 + hrp/internal/boomer/ulimit.go | 32 + hrp/internal/boomer/ulimit_windows.go | 12 + hrp/internal/boomer/utils.go | 77 + hrp/internal/boomer/utils_test.go | 73 + hrp/internal/builtin/assertion.go | 208 + hrp/internal/builtin/assertion_test.go | 191 + hrp/internal/builtin/function.go | 260 + hrp/internal/ga/client.go | 76 + hrp/internal/ga/client_test.go | 30 + hrp/internal/ga/events.go | 71 + hrp/internal/ga/init.go | 25 + hrp/internal/har2case/README.md | 9 + hrp/internal/har2case/core.go | 352 ++ hrp/internal/har2case/core_test.go | 122 + hrp/internal/har2case/har.go | 340 ++ hrp/internal/json/json.go | 16 + hrp/internal/report/template.html | 359 ++ hrp/internal/scaffold/demo.go | 260 + hrp/internal/scaffold/demo_test.go | 75 + hrp/internal/scaffold/main.go | 150 + hrp/internal/version/init.go | 3 + hrp/models.go | 454 ++ hrp/parser.go | 730 +++ hrp/parser_test.go | 866 +++ hrp/plugin.go | 116 + hrp/plugin_test.go | 62 + hrp/response.go | 226 + hrp/response_test.go | 35 + hrp/runner.go | 1075 ++++ hrp/runner_test.go | 231 + hrp/step.go | 457 ++ hrp/step_test.go | 89 + hrp/validate.go | 223 + httprunner/__init__.py | 3 +- main.go | 9 + pyproject.toml | 2 +- scripts/build.sh | 23 + scripts/bump_version.sh | 29 + scripts/install.sh | 124 + 122 files changed, 23288 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/hrp-release.yml create mode 100644 .github/workflows/hrp-scaffold.yml rename .github/workflows/{integration_test.yml => smoketest.yml} (90%) create mode 100644 Makefile create mode 100644 docs/cmd/hrp.md create mode 100644 docs/cmd/hrp_boom.md create mode 100644 docs/cmd/hrp_har2case.md create mode 100644 docs/cmd/hrp_run.md create mode 100644 docs/cmd/hrp_startproject.md create mode 100644 examples/hrp/__init__.py create mode 100644 examples/hrp/account.csv create mode 100644 examples/hrp/api/get.json create mode 100644 examples/hrp/api/get.yml create mode 100644 examples/hrp/api/post.json create mode 100644 examples/hrp/api/post.yml create mode 100644 examples/hrp/api/put.json create mode 100644 examples/hrp/api/put.yml create mode 100644 examples/hrp/compat_test.go create mode 100644 examples/hrp/debugtalk.py create mode 100644 examples/hrp/demo.json create mode 100644 examples/hrp/demo.yaml create mode 100644 examples/hrp/demo_httprunner.json create mode 100644 examples/hrp/demo_httprunner.yaml create mode 100644 examples/hrp/demo_test.py create mode 100644 examples/hrp/extract_test.go create mode 100644 examples/hrp/function_test.go create mode 100644 examples/hrp/har/demo.har create mode 100644 examples/hrp/har/demo.json create mode 100644 examples/hrp/har/postman-echo.har create mode 100644 examples/hrp/har/postman-echo.yaml create mode 100644 examples/hrp/httpbin.json create mode 100644 examples/hrp/parameters_test.json create mode 100644 examples/hrp/parameters_test.yaml create mode 100644 examples/hrp/plugin/debugtalk.go create mode 100644 examples/hrp/plugin/hashicorp.go create mode 100644 examples/hrp/postman-echo.json create mode 100644 examples/hrp/postman-echo.yaml create mode 100644 examples/hrp/ref_api_test.json create mode 100644 examples/hrp/ref_api_test.yaml create mode 100644 examples/hrp/ref_testcase_test.json create mode 100644 examples/hrp/ref_testcase_test.yaml create mode 100644 examples/hrp/rendezvous_test.go create mode 100644 examples/hrp/rendezvous_test.json create mode 100644 examples/hrp/request_test.go create mode 100644 examples/hrp/think_time_test.json create mode 100644 examples/hrp/think_time_test.yaml create mode 100644 examples/hrp/validate_test.go create mode 100644 examples/hrp/variables_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hrp/README.md create mode 100644 hrp/boomer.go create mode 100644 hrp/boomer_test.go create mode 100644 hrp/cmd/boom.go create mode 100644 hrp/cmd/doc_test.go create mode 100644 hrp/cmd/har2case.go create mode 100644 hrp/cmd/root.go create mode 100644 hrp/cmd/run.go create mode 100644 hrp/cmd/scaffold.go create mode 100644 hrp/convert.go create mode 100644 hrp/convert_test.go create mode 100644 hrp/docs/CHANGELOG.md create mode 100644 hrp/docs/README.md create mode 100644 hrp/docs/assets/flow.jpg create mode 100644 hrp/docs/assets/hogwarts.jpeg create mode 100644 hrp/docs/assets/qrcode.jpg create mode 100644 hrp/docs/assets/sentry-logo-black.svg create mode 100644 hrp/docs/builtin.md create mode 100644 hrp/extract.go create mode 100644 hrp/internal/boomer/README.md create mode 100644 hrp/internal/boomer/boomer.go create mode 100644 hrp/internal/boomer/boomer_test.go create mode 100644 hrp/internal/boomer/output.go create mode 100644 hrp/internal/boomer/output_test.go create mode 100644 hrp/internal/boomer/ratelimiter.go create mode 100644 hrp/internal/boomer/ratelimiter_test.go create mode 100644 hrp/internal/boomer/runner.go create mode 100644 hrp/internal/boomer/runner_test.go create mode 100644 hrp/internal/boomer/stats.go create mode 100644 hrp/internal/boomer/stats_test.go create mode 100644 hrp/internal/boomer/task.go create mode 100644 hrp/internal/boomer/ulimit.go create mode 100644 hrp/internal/boomer/ulimit_windows.go create mode 100644 hrp/internal/boomer/utils.go create mode 100644 hrp/internal/boomer/utils_test.go create mode 100644 hrp/internal/builtin/assertion.go create mode 100644 hrp/internal/builtin/assertion_test.go create mode 100644 hrp/internal/builtin/function.go create mode 100644 hrp/internal/ga/client.go create mode 100644 hrp/internal/ga/client_test.go create mode 100644 hrp/internal/ga/events.go create mode 100644 hrp/internal/ga/init.go create mode 100644 hrp/internal/har2case/README.md create mode 100644 hrp/internal/har2case/core.go create mode 100644 hrp/internal/har2case/core_test.go create mode 100644 hrp/internal/har2case/har.go create mode 100644 hrp/internal/json/json.go create mode 100644 hrp/internal/report/template.html create mode 100644 hrp/internal/scaffold/demo.go create mode 100644 hrp/internal/scaffold/demo_test.go create mode 100644 hrp/internal/scaffold/main.go create mode 100644 hrp/internal/version/init.go create mode 100644 hrp/models.go create mode 100644 hrp/parser.go create mode 100644 hrp/parser_test.go create mode 100644 hrp/plugin.go create mode 100644 hrp/plugin_test.go create mode 100644 hrp/response.go create mode 100644 hrp/response_test.go create mode 100644 hrp/runner.go create mode 100644 hrp/runner_test.go create mode 100644 hrp/step.go create mode 100644 hrp/step_test.go create mode 100644 hrp/validate.go create mode 100644 main.go create mode 100644 scripts/build.sh create mode 100644 scripts/bump_version.sh create mode 100644 scripts/install.sh diff --git a/.github/workflows/hrp-release.yml b/.github/workflows/hrp-release.yml new file mode 100644 index 00000000..2bed01de --- /dev/null +++ b/.github/workflows/hrp-release.yml @@ -0,0 +1,45 @@ +name: Release hrp cli binaries + +on: + release: + types: [created] + +jobs: + releases-matrix: + name: Release hrp cli binaries + runs-on: ubuntu-latest + strategy: + matrix: + # build and publish in parallel: linux/amd64/windows + goos: [linux, windows, darwin] + goarch: [amd64, arm64] + exclude: + - goarch: arm64 + goos: windows + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Release hrp cli binaries + uses: wangyoucao577/go-release-action@v1.23 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + goos: ${{ matrix.goos }} + goarch: ${{ matrix.goarch }} + project_path: "." # go build ./main.go + binary_name: "hrp" + ldflags: "-s -w" + extra_files: LICENSE README.md docs/CHANGELOG.md + post_command: | + echo "ASSET_PATH=$INPUT_PROJECT_PATH/$BUILD_ARTIFACTS_FOLDER/$RELEASE_ASSET_FILE" >> $GITHUB_ENV + - name: Setup aliyun OSS + uses: manyuanrong/setup-ossutil@v2.0 + with: + endpoint: "oss-cn-beijing.aliyuncs.com" + access-key-id: ${{ secrets.ALIYUN_ACCESSKEY_ID }} + access-key-secret: ${{ secrets.ALIYUN_ACCESSKEY_SECRET }} + - name: Upload artifacts to aliyun OSS + run: | + ossutil cp -rf scripts/install.sh oss://httprunner/ + ossutil cp -rf ${{ env.ASSET_PATH }} oss://httprunner/ + - name: Test install.sh + run: bash -c "$(curl -ksSL https://httprunner.oss-cn-beijing.aliyuncs.com/install.sh)" diff --git a/.github/workflows/hrp-scaffold.yml b/.github/workflows/hrp-scaffold.yml new file mode 100644 index 00000000..81116817 --- /dev/null +++ b/.github/workflows/hrp-scaffold.yml @@ -0,0 +1,73 @@ +name: Run scaffold for hrp + +on: + push: + pull_request: + types: [synchronize] + +jobs: + scaffold-with-python-plugin: + strategy: + fail-fast: false + matrix: + go-version: + - 1.17.x + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v2 + - name: Build hrp binary + run: make build + - name: Run start project + run: ./output/hrp startproject demo + - name: Run demo tests + run: ./output/hrp run demo/testcases/demo.json demo/testcases/demo.yaml + + scaffold-with-go-plugin: + strategy: + fail-fast: false + matrix: + go-version: + - 1.17.x + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v2 + - name: Build hrp binary + run: make build + - name: Run start project + run: ./output/hrp startproject demo --go + - name: Run demo tests + run: ./output/hrp run demo/testcases/demo.json demo/testcases/demo.yaml + + scaffold-without-custom-plugin: + strategy: + fail-fast: false + matrix: + go-version: + - 1.17.x + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v2 + - name: Build hrp binary + run: make build + - name: Run start project + run: ./output/hrp startproject demo --ignore-plugin + - name: Run demo tests + run: ./output/hrp run demo/testcases/demo.json demo/testcases/demo.yaml diff --git a/.github/workflows/integration_test.yml b/.github/workflows/smoketest.yml similarity index 90% rename from .github/workflows/integration_test.yml rename to .github/workflows/smoketest.yml index 8050ddd4..28196ba4 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/smoketest.yml @@ -1,4 +1,4 @@ -name: integration_test +name: run smoke tests for httprunner on: push: @@ -6,9 +6,9 @@ on: types: [synchronize] jobs: - integration_test: + smoke-test: - name: integration_test - ${{ matrix.python-version }} on ${{ matrix.os }} + name: smoketest - ${{ matrix.python-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 769b6f71..a2aec766 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -1,4 +1,4 @@ -name: unittest +name: Run unittests on: push: @@ -6,9 +6,7 @@ on: types: [synchronize] jobs: - unittest: - - name: unittest - ${{ matrix.python-version }} on ${{ matrix.os }} + py-httprunner: runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -54,3 +52,34 @@ jobs: flags: unittests # Specify whether or not CI build should fail if Codecov runs into an error during upload fail_ci_if_error: true + + go-hrp: + strategy: + fail-fast: false + matrix: + go-version: + - 1.16.x + - 1.17.x + - 1.18.x + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Install Python plugin dependencies + run: python3 -m pip install funppy + - name: Checkout code + uses: actions/checkout@v2 + - name: Run coverage + run: go test -coverprofile="cover.out" -covermode=atomic -race ./... + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + name: hrp (HttpRunner+) # User defined upload name. Visible in Codecov UI + token: ${{ secrets.CODECOV_TOKEN }} # Repository upload token + file: ./cover.out # Path to coverage file to upload + flags: unittests # Flag upload to group coverage metrics + fail_ci_if_error: true # Specify whether or not CI build should fail if Codecov runs into an error during upload + verbose: true diff --git a/.gitignore b/.gitignore index b67029dd..9be65699 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,41 @@ -*.pyc -__pycache__ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# system or IDE generated files +__debug_bin +.vscode/ +.idea/ .DS_Store -.vscode -.idea -.pypirc -*/tmp/* -build/* -dist/* -*.egg-info -.python-version +*.bak + +# project output files +site/ +output/ logs .coverage -site/ reports -.venv *.xml -htmlcov/ \ No newline at end of file +htmlcov/ + +# built plugins +debugtalk.bin +debugtalk.so + +# python files +.venv +__pycache__ +*.pyc +dist +*.egg-info +.python-version +.pytest_cache diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..e999b732 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +SHELL=/usr/bin/env bash + +.DEFAULT_GOAL=help + +.PHONY: test +test: ## run unit tests + @echo "[info] run unit tests" + @echo "go test -race -v ./..." + @go test -race -v ./... + +.PHONY: bump +bump: ## bump hrp version, e.g. make bump version=4.0.0 + @echo "[info] bump hrp version" + @. scripts/bump_version.sh $(version) + +.PHONY: build +build: ## build hrp cli tool + @echo "[info] build hrp cli tool" + @. scripts/build.sh + +.PHONY: help +help: ## print make commands + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + cut -d ":" -f1- | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/docs/cmd/hrp.md b/docs/cmd/hrp.md new file mode 100644 index 00000000..cdfed299 --- /dev/null +++ b/docs/cmd/hrp.md @@ -0,0 +1,36 @@ +## hrp + +One-stop solution for HTTP(S) testing. + +### Synopsis + + +██╗ ██╗████████╗████████╗██████╗ ██████╗ ██╗ ██╗███╗ ██╗███╗ ██╗███████╗██████╗ +██║ ██║╚══██╔══╝╚══██╔══╝██╔══██╗██╔══██╗██║ ██║████╗ ██║████╗ ██║██╔════╝██╔══██╗ +███████║ ██║ ██║ ██████╔╝██████╔╝██║ ██║██╔██╗ ██║██╔██╗ ██║█████╗ ██████╔╝ +██╔══██║ ██║ ██║ ██╔═══╝ ██╔══██╗██║ ██║██║╚██╗██║██║╚██╗██║██╔══╝ ██╔══██╗ +██║ ██║ ██║ ██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║██║ ╚████║███████╗██║ ██║ +╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ + +hrp (HttpRunner+) aims to be a one-stop solution for HTTP(S) testing, covering API testing, +load testing and digital experience monitoring (DEM). Enjoy! ✨ 🚀 ✨ + +License: Apache-2.0 +Website: https://httprunner.com +Github: https://github.com/httprunner/httprunner/hrp +Copyright 2021 debugtalk + +### Options + +``` + -h, --help help for hrp +``` + +### SEE ALSO + +* [hrp boom](hrp_boom.md) - run load test with boomer +* [hrp har2case](hrp_har2case.md) - convert HAR to json/yaml testcase files +* [hrp run](hrp_run.md) - run API test +* [hrp startproject](hrp_startproject.md) - create a scaffold project + +###### Auto generated by spf13/cobra on 23-Mar-2022 diff --git a/docs/cmd/hrp_boom.md b/docs/cmd/hrp_boom.md new file mode 100644 index 00000000..fabc691d --- /dev/null +++ b/docs/cmd/hrp_boom.md @@ -0,0 +1,44 @@ +## hrp boom + +run load test with boomer + +### Synopsis + +run yaml/json testcase files for load test + +``` +hrp boom [flags] +``` + +### Examples + +``` + $ hrp boom demo.json # run specified json testcase file + $ hrp boom demo.yaml # run specified yaml testcase file + $ hrp boom examples/ # run testcases in specified folder +``` + +### Options + +``` + --cpu-profile string Enable CPU profiling. + --cpu-profile-duration duration CPU profile duration. (default 30s) + --disable-compression Disable compression + --disable-console-output Disable console output. + --disable-keepalive Disable keepalive + -h, --help help for boom + --loop-count int The specify running cycles for load testing (default -1) + --max-rps int Max RPS that boomer can generate, disabled by default. + --mem-profile string Enable memory profiling. + --mem-profile-duration duration Memory profile duration. (default 30s) + --prometheus-gateway string Prometheus Pushgateway url. + --request-increase-rate string Request increase rate, disabled by default. (default "-1") + --spawn-count int The number of users to spawn for load testing (default 1) + --spawn-rate float The rate for spawning users (default 1) +``` + +### SEE ALSO + +* [hrp](hrp.md) - One-stop solution for HTTP(S) testing. + +###### Auto generated by spf13/cobra on 23-Mar-2022 diff --git a/docs/cmd/hrp_har2case.md b/docs/cmd/hrp_har2case.md new file mode 100644 index 00000000..036db7c0 --- /dev/null +++ b/docs/cmd/hrp_har2case.md @@ -0,0 +1,26 @@ +## hrp har2case + +convert HAR to json/yaml testcase files + +### Synopsis + +convert HAR to json/yaml testcase files + +``` +hrp har2case $har_path... [flags] +``` + +### Options + +``` + -h, --help help for har2case + -d, --output-dir string specify output directory, default to the same dir with har file + -j, --to-json convert to JSON format (default true) + -y, --to-yaml convert to YAML format +``` + +### SEE ALSO + +* [hrp](hrp.md) - One-stop solution for HTTP(S) testing. + +###### Auto generated by spf13/cobra on 23-Mar-2022 diff --git a/docs/cmd/hrp_run.md b/docs/cmd/hrp_run.md new file mode 100644 index 00000000..d066ff6d --- /dev/null +++ b/docs/cmd/hrp_run.md @@ -0,0 +1,37 @@ +## hrp run + +run API test + +### Synopsis + +run yaml/json testcase files for API test + +``` +hrp run $path... [flags] +``` + +### Examples + +``` + $ hrp run demo.json # run specified json testcase file + $ hrp run demo.yaml # run specified yaml testcase file + $ hrp run examples/ # run testcases in specified folder +``` + +### Options + +``` + -c, --continue-on-failure continue running next step when failure occurs + -g, --gen-html-report generate html report + -h, --help help for run + --log-plugin turn on plugin logging + --log-requests-off turn off request & response details logging + -p, --proxy-url string set proxy url + -s, --save-tests save tests summary +``` + +### SEE ALSO + +* [hrp](hrp.md) - One-stop solution for HTTP(S) testing. + +###### Auto generated by spf13/cobra on 23-Mar-2022 diff --git a/docs/cmd/hrp_startproject.md b/docs/cmd/hrp_startproject.md new file mode 100644 index 00000000..55c4934c --- /dev/null +++ b/docs/cmd/hrp_startproject.md @@ -0,0 +1,22 @@ +## hrp startproject + +create a scaffold project + +``` +hrp startproject $project_name [flags] +``` + +### Options + +``` + --go generate hashicorp go plugin + -h, --help help for startproject + --ignore-plugin ignore function plugin + --py generate hashicorp python plugin (default true) +``` + +### SEE ALSO + +* [hrp](hrp.md) - One-stop solution for HTTP(S) testing. + +###### Auto generated by spf13/cobra on 23-Mar-2022 diff --git a/examples/hrp/__init__.py b/examples/hrp/__init__.py new file mode 100644 index 00000000..70cfba53 --- /dev/null +++ b/examples/hrp/__init__.py @@ -0,0 +1 @@ +# NOTICE: Generated By HttpRunner. DO NOT EDIT! diff --git a/examples/hrp/account.csv b/examples/hrp/account.csv new file mode 100644 index 00000000..67ce22c6 --- /dev/null +++ b/examples/hrp/account.csv @@ -0,0 +1,4 @@ +username,password +test1,111111 +test2,222222 +test3,333333 \ No newline at end of file diff --git a/examples/hrp/api/get.json b/examples/hrp/api/get.json new file mode 100644 index 00000000..14730e69 --- /dev/null +++ b/examples/hrp/api/get.json @@ -0,0 +1,34 @@ +{ + "name": "", + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "bar1", + "foo2": "bar2" + }, + "headers": { + "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b254c2723" + } + }, + "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=bar1&foo2=bar2", + "msg": "assert response body url" + } + ] +} \ No newline at end of file diff --git a/examples/hrp/api/get.yml b/examples/hrp/api/get.yml new file mode 100644 index 00000000..de44702b --- /dev/null +++ b/examples/hrp/api/get.yml @@ -0,0 +1,22 @@ +name: "" +request: + method: GET + url: /get + params: + foo1: bar1 + foo2: bar2 + headers: + Postman-Token: ea19464c-ddd4-4724-abe9-5e2b254c2723 +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=bar1&foo2=bar2 + msg: assert response body url \ No newline at end of file diff --git a/examples/hrp/api/post.json b/examples/hrp/api/post.json new file mode 100644 index 00000000..a0be491f --- /dev/null +++ b/examples/hrp/api/post.json @@ -0,0 +1,45 @@ +{ + "name": "", + "request": { + "method": "POST", + "url": "/post", + "headers": { + "Content-Length": "58", + "Content-Type": "text/plain", + "Postman-Token": "$session_token" + }, + "body": "This is expected to be sent back as part of response body." + }, + "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": "This is expected to be sent back as part of response body.", + "msg": "assert response body data" + }, + { + "check": "body.json", + "assert": "equals", + "expect": null, + "msg": "assert response body json" + }, + { + "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/hrp/api/post.yml b/examples/hrp/api/post.yml new file mode 100644 index 00000000..1cfac61a --- /dev/null +++ b/examples/hrp/api/post.yml @@ -0,0 +1,30 @@ +name: "" +request: + method: POST + url: /post + headers: + Content-Length: "58" + Content-Type: text/plain + Postman-Token: $session_token + body: This is expected to be sent back as part of response body. +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: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - 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/hrp/api/put.json b/examples/hrp/api/put.json new file mode 100644 index 00000000..f68fa7e7 --- /dev/null +++ b/examples/hrp/api/put.json @@ -0,0 +1,45 @@ +{ + "name": "", + "request": { + "method": "PUT", + "url": "/put", + "headers": { + "Content-Length": "58", + "Content-Type": "text/plain", + "Postman-Token": "5d357b2b-0f10-4ded-bc9a-299ebef7a2d5" + }, + "body": "This is expected to be sent back as part of response body." + }, + "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": "This is expected to be sent back as part of response body.", + "msg": "assert response body data" + }, + { + "check": "body.json", + "assert": "equals", + "expect": null, + "msg": "assert response body json" + }, + { + "check": "body.url", + "assert": "equals", + "expect": "https://postman-echo.com/put", + "msg": "assert response body url" + } + ] +} \ No newline at end of file diff --git a/examples/hrp/api/put.yml b/examples/hrp/api/put.yml new file mode 100644 index 00000000..4fa1abff --- /dev/null +++ b/examples/hrp/api/put.yml @@ -0,0 +1,30 @@ +name: "" +request: + method: PUT + url: /put + headers: + Content-Length: "58" + Content-Type: text/plain + Postman-Token: 5d357b2b-0f10-4ded-bc9a-299ebef7a2d5 + body: This is expected to be sent back as part of response body. +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: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - check: body.url + assert: equals + expect: https://postman-echo.com/put + msg: assert response body url \ No newline at end of file diff --git a/examples/hrp/compat_test.go b/examples/hrp/compat_test.go new file mode 100644 index 00000000..a66eb843 --- /dev/null +++ b/examples/hrp/compat_test.go @@ -0,0 +1,25 @@ +package examples + +import ( + "testing" + + "github.com/httprunner/httprunner/hrp" +) + +// generated by examples/hrp/har/demo.har using HttpRunner v3.1.6 +var ( + demoHttpRunnerJSONPath hrp.TestCasePath = "demo_httprunner.json" + demoHttpRunnerYAMLPath hrp.TestCasePath = "demo_httprunner.yaml" +) + +func TestCompatTestCase(t *testing.T) { + err := hrp.NewRunner(t).Run(&demoHttpRunnerJSONPath) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } + + err = hrp.NewRunner(t).Run(&demoHttpRunnerYAMLPath) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} diff --git a/examples/hrp/debugtalk.py b/examples/hrp/debugtalk.py new file mode 100644 index 00000000..3d2bb5ff --- /dev/null +++ b/examples/hrp/debugtalk.py @@ -0,0 +1,55 @@ +import logging +from typing import List + +import funppy + + +def sum(*args): + result = 0 + for arg in args: + result += arg + return result + +def sum_ints(*args: List[int]) -> int: + result = 0 + for arg in args: + result += arg + return result + +def sum_two_int(a: int, b: int) -> int: + return a + b + +def sum_two_string(a: str, b: str) -> str: + return a + b + +def sum_strings(*args: List[str]) -> str: + result = "" + for arg in args: + result += arg + return result + +def concatenate(*args: List[str]) -> str: + result = "" + for arg in args: + result += str(arg) + return result + +def setup_hook_example(name): + logging.warning("setup_hook_example") + return f"setup_hook_example: {name}" + +def teardown_hook_example(name): + logging.warning("teardown_hook_example") + return f"teardown_hook_example: {name}" + + +if __name__ == '__main__': + funppy.register("sum", sum) + funppy.register("sum_ints", sum_ints) + funppy.register("concatenate", concatenate) + funppy.register("sum_two_int", sum_two_int) + funppy.register("sum_two_string", sum_two_string) + funppy.register("sum_strings", sum_strings) + funppy.register("setup_hook_example", setup_hook_example) + funppy.register("teardown_hook_example", teardown_hook_example) + funppy.serve() diff --git a/examples/hrp/demo.json b/examples/hrp/demo.json new file mode 100644 index 00000000..1bb63ed8 --- /dev/null +++ b/examples/hrp/demo.json @@ -0,0 +1,176 @@ +{ + "config": { + "name": "demo with complex mechanisms", + "base_url": "https://postman-echo.com", + "variables": { + "a": "${sum(10, 2.3)}", + "b": 3.45, + "n": "${sum_ints(1, 2, 2)}", + "varFoo1": "${gen_random_string($n)}", + "varFoo2": "${max($a, $b)}" + } + }, + "teststeps": [ + { + "name": "transaction 1 start", + "transaction": { + "name": "tran1", + "type": "start" + } + }, + { + "name": "get with params", + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "$varFoo1", + "foo2": "$varFoo2" + }, + "headers": { + "User-Agent": "HttpRunnerPlus" + } + }, + "variables": { + "b": 34.5, + "n": 3, + "name": "get with params", + "varFoo2": "${max($a, $b)}" + }, + "setup_hooks": [ + "${setup_hook_example($name)}" + ], + "teardown_hooks": [ + "${teardown_hook_example($name)}" + ], + "extract": { + "varFoo1": "body.args.foo1" + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "check response status code" + }, + { + "check": "headers.\"Content-Type\"", + "assert": "startswith", + "expect": "application/json" + }, + { + "check": "body.args.foo1", + "assert": "length_equals", + "expect": 5, + "msg": "check args foo1" + }, + { + "check": "$varFoo1", + "assert": "length_equals", + "expect": 5, + "msg": "check args foo1" + }, + { + "check": "body.args.foo2", + "assert": "equals", + "expect": "34.5", + "msg": "check args foo2" + } + ] + }, + { + "name": "transaction 1 end", + "transaction": { + "name": "tran1", + "type": "end" + } + }, + { + "name": "post json data", + "request": { + "method": "POST", + "url": "/post", + "body": { + "foo1": "$varFoo1", + "foo2": "${max($a, $b)}" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "check status code" + }, + { + "check": "body.json.foo1", + "assert": "length_equals", + "expect": 5, + "msg": "check args foo1" + }, + { + "check": "body.json.foo2", + "assert": "equals", + "expect": 12.3, + "msg": "check args foo2" + } + ] + }, + { + "name": "post form data", + "request": { + "method": "POST", + "url": "/post", + "headers": { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" + }, + "body": { + "foo1": "$varFoo1", + "foo2": "${max($a, $b)}", + "time": "${get_timestamp()}" + } + }, + "extract": { + "varTime": "body.form.time" + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "check status code" + }, + { + "check": "body.form.foo1", + "assert": "length_equals", + "expect": 5, + "msg": "check args foo1" + }, + { + "check": "body.form.foo2", + "assert": "equals", + "expect": "12.3", + "msg": "check args foo2" + } + ] + }, + { + "name": "get with timestamp", + "request": { + "method": "GET", + "url": "/get", + "params": { + "time": "$varTime" + } + }, + "validate": [ + { + "check": "body.args.time", + "assert": "length_equals", + "expect": 13, + "msg": "check extracted var timestamp" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/hrp/demo.yaml b/examples/hrp/demo.yaml new file mode 100644 index 00000000..387b9345 --- /dev/null +++ b/examples/hrp/demo.yaml @@ -0,0 +1,114 @@ +config: + name: demo with complex mechanisms + base_url: https://postman-echo.com + variables: + a: ${sum(10, 2.3)} + b: 3.45 + "n": ${sum_ints(1, 2, 2)} + varFoo1: ${gen_random_string($n)} + varFoo2: ${max($a, $b)} +teststeps: + - name: transaction 1 start + transaction: + name: tran1 + type: start + - name: get with params + request: + method: GET + url: /get + params: + foo1: $varFoo1 + foo2: $varFoo2 + headers: + User-Agent: HttpRunnerPlus + variables: + b: 34.5 + "n": 3 + name: get with params + varFoo2: ${max($a, $b)} + setup_hooks: + - ${setup_hook_example($name)} + teardown_hooks: + - ${teardown_hook_example($name)} + extract: + varFoo1: body.args.foo1 + validate: + - check: status_code + assert: equals + expect: 200 + msg: check response status code + - check: headers."Content-Type" + assert: startswith + expect: application/json + - check: body.args.foo1 + assert: length_equals + expect: 5 + msg: check args foo1 + - check: $varFoo1 + assert: length_equals + expect: 5 + msg: check args foo1 + - check: body.args.foo2 + assert: equals + expect: "34.5" + msg: check args foo2 + - name: transaction 1 end + transaction: + name: tran1 + type: end + - name: post json data + request: + method: POST + url: /post + body: + foo1: $varFoo1 + foo2: ${max($a, $b)} + validate: + - check: status_code + assert: equals + expect: 200 + msg: check status code + - check: body.json.foo1 + assert: length_equals + expect: 5 + msg: check args foo1 + - check: body.json.foo2 + assert: equals + expect: 12.3 + msg: check args foo2 + - name: post form data + request: + method: POST + url: /post + headers: + Content-Type: application/x-www-form-urlencoded; charset=UTF-8 + body: + foo1: $varFoo1 + foo2: ${max($a, $b)} + time: ${get_timestamp()} + extract: + varTime: body.form.time + validate: + - check: status_code + assert: equals + expect: 200 + msg: check status code + - check: body.form.foo1 + assert: length_equals + expect: 5 + msg: check args foo1 + - check: body.form.foo2 + assert: equals + expect: "12.3" + msg: check args foo2 + - name: get with timestamp + request: + method: GET + url: /get + params: + time: $varTime + validate: + - check: body.args.time + assert: length_equals + expect: 13 + msg: check extracted var timestamp diff --git a/examples/hrp/demo_httprunner.json b/examples/hrp/demo_httprunner.json new file mode 100644 index 00000000..de017c96 --- /dev/null +++ b/examples/hrp/demo_httprunner.json @@ -0,0 +1,135 @@ +{ + "config": { + "name": "testcase description", + "variables": {}, + "verify": false + }, + "teststeps": [ + { + "name": "/get", + "request": { + "url": "https://postman-echo.com/get", + "params": { + "foo1": "HDnY8", + "foo2": "34.5" + }, + "method": "GET", + "headers": { + "Host": "postman-echo.com", + "User-Agent": "HttpRunnerPlus", + "Accept-Encoding": "gzip" + } + }, + "validate": [ + { + "eq": [ + "status_code", + 200 + ] + }, + { + "eq": [ + "headers.Content-Type", + "application/json; charset=utf-8" + ] + }, + { + "eq": [ + "body.url", + "https://postman-echo.com/get?foo1=HDnY8&foo2=34.5" + ] + } + ] + }, + { + "name": "/post", + "request": { + "url": "https://postman-echo.com/post", + "method": "POST", + "cookies": { + "sails.sid": "s%3Az_LpglkKxTvJ_eHVUH6V67drKp0AGWW-.PidabaXOnatLRP47hVyqqepl6BdrpEQzRlJQXtbIiwk" + }, + "headers": { + "Host": "postman-echo.com", + "User-Agent": "Go-http-client/1.1", + "Content-Length": "28", + "Content-Type": "application/json; charset=UTF-8", + "Cookie": "sails.sid=s%3Az_LpglkKxTvJ_eHVUH6V67drKp0AGWW-.PidabaXOnatLRP47hVyqqepl6BdrpEQzRlJQXtbIiwk", + "Accept-Encoding": "gzip" + }, + "json": { + "foo1": "HDnY8", + "foo2": 12.3 + } + }, + "validate": [ + { + "eq": [ + "status_code", + 200 + ] + }, + { + "eq": [ + "headers.Content-Type", + "application/json; charset=utf-8" + ] + }, + { + "eq": [ + "body.url", + "https://postman-echo.com/post" + ] + } + ] + }, + { + "name": "/post", + "request": { + "url": "https://postman-echo.com/post", + "method": "POST", + "cookies": { + "sails.sid": "s%3AS5e7w0zQ0xAsCwh9L8T6R7QLYCO7_gtD.r8%2B2w9IWqEIfuVkrZjnxzm2xADIk34zKAWXRPapr%2FAw" + }, + "headers": { + "Host": "postman-echo.com", + "User-Agent": "Go-http-client/1.1", + "Content-Length": "20", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Cookie": "sails.sid=s%3AS5e7w0zQ0xAsCwh9L8T6R7QLYCO7_gtD.r8%2B2w9IWqEIfuVkrZjnxzm2xADIk34zKAWXRPapr%2FAw", + "Accept-Encoding": "gzip" + }, + "data": { + "foo1": "HDnY8", + "foo2": "12.3" + } + }, + "validate": [ + { + "eq": [ + "status_code", + 200 + ] + }, + { + "eq": [ + "headers.Content-Type", + "application/json; charset=utf-8" + ] + }, + { + "eq": [ + "body.data", + "" + ] + }, + { + "eq": [ + "body.url", + "https://postman-echo.com/post" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/hrp/demo_httprunner.yaml b/examples/hrp/demo_httprunner.yaml new file mode 100644 index 00000000..0f39723f --- /dev/null +++ b/examples/hrp/demo_httprunner.yaml @@ -0,0 +1,81 @@ +config: + name: testcase description + variables: {} + verify: false +teststeps: +- name: /get + request: + headers: + Accept-Encoding: gzip + Host: postman-echo.com + User-Agent: HttpRunnerPlus + method: GET + params: + foo1: HDnY8 + foo2: '34.5' + url: https://postman-echo.com/get + validate: + - eq: + - status_code + - 200 + - eq: + - headers.Content-Type + - application/json; charset=utf-8 + - eq: + - body.url + - https://postman-echo.com/get?foo1=HDnY8&foo2=34.5 +- name: /post + request: + cookies: + sails.sid: s%3Az_LpglkKxTvJ_eHVUH6V67drKp0AGWW-.PidabaXOnatLRP47hVyqqepl6BdrpEQzRlJQXtbIiwk + headers: + Accept-Encoding: gzip + Content-Length: '28' + Content-Type: application/json; charset=UTF-8 + Cookie: sails.sid=s%3Az_LpglkKxTvJ_eHVUH6V67drKp0AGWW-.PidabaXOnatLRP47hVyqqepl6BdrpEQzRlJQXtbIiwk + Host: postman-echo.com + User-Agent: Go-http-client/1.1 + json: + foo1: HDnY8 + foo2: 12.3 + method: POST + url: https://postman-echo.com/post + validate: + - eq: + - status_code + - 200 + - eq: + - headers.Content-Type + - application/json; charset=utf-8 + - eq: + - body.url + - https://postman-echo.com/post +- name: /post + request: + cookies: + sails.sid: s%3AS5e7w0zQ0xAsCwh9L8T6R7QLYCO7_gtD.r8%2B2w9IWqEIfuVkrZjnxzm2xADIk34zKAWXRPapr%2FAw + data: + foo1: HDnY8 + foo2: '12.3' + headers: + Accept-Encoding: gzip + Content-Length: '20' + Content-Type: application/x-www-form-urlencoded; charset=UTF-8 + Cookie: sails.sid=s%3AS5e7w0zQ0xAsCwh9L8T6R7QLYCO7_gtD.r8%2B2w9IWqEIfuVkrZjnxzm2xADIk34zKAWXRPapr%2FAw + Host: postman-echo.com + User-Agent: Go-http-client/1.1 + method: POST + url: https://postman-echo.com/post + validate: + - eq: + - status_code + - 200 + - eq: + - headers.Content-Type + - application/json; charset=utf-8 + - eq: + - body.data + - '' + - eq: + - body.url + - https://postman-echo.com/post diff --git a/examples/hrp/demo_test.py b/examples/hrp/demo_test.py new file mode 100644 index 00000000..e2eddc1f --- /dev/null +++ b/examples/hrp/demo_test.py @@ -0,0 +1,63 @@ +# NOTE: Generated By HttpRunner v3.1.6 +# FROM: hrp/examples/demo.json + + +from httprunner import HttpRunner, Config, Step, RunRequest, RunTestCase + + +class TestCaseDemo(HttpRunner): + + config = ( + Config("demo with complex mechanisms") + .variables( + **{ + "a": 12.3, + "b": 3.45, + "n": 5, + "varFoo1": "${gen_random_string($n)}", + "varFoo2": "${max($a, $b)}", + } + ) + .base_url("https://postman-echo.com") + ) + + teststeps = [ + Step( + RunRequest("get with params") + .with_variables(**{"b": 34.5, "n": 3, "varFoo2": "${max($a, $b)}"}) + .get("/get") + .with_params(**{"foo1": "$varFoo1", "foo2": "$varFoo2"}) + .with_headers(**{"User-Agent": "HttpRunnerPlus"}) + .extract() + .with_jmespath("body.args.foo1", "varFoo1") + .validate() + .assert_equal("status_code", 200) + .assert_equal('headers."Content-Type"', "application/json") + .assert_equal("body.args.foo1", 5) + .assert_equal("$varFoo1", 5) + .assert_equal("body.args.foo2", "34.5") + ), + Step( + RunRequest("post json data") + .post("/post") + .validate() + .assert_equal("status_code", 200) + .assert_equal("body.json.foo1", 5) + .assert_equal("body.json.foo2", 12.3) + ), + Step( + RunRequest("post form data") + .post("/post") + .with_headers( + **{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"} + ) + .validate() + .assert_equal("status_code", 200) + .assert_equal("body.form.foo1", 5) + .assert_equal("body.form.foo2", "12.3") + ), + ] + + +if __name__ == "__main__": + TestCaseDemo().test_start() diff --git a/examples/hrp/extract_test.go b/examples/hrp/extract_test.go new file mode 100644 index 00000000..ae29b623 --- /dev/null +++ b/examples/hrp/extract_test.go @@ -0,0 +1,84 @@ +package examples + +import ( + "testing" + + "github.com/httprunner/httprunner/hrp" +) + +// reference extracted variables for validation in the same step +func TestCaseExtractStepSingle(t *testing.T) { + testcase := &hrp.TestCase{ + Config: hrp.NewConfig("run request with variables"). + SetBaseURL("https://postman-echo.com"). + SetVerifySSL(false), + TestSteps: []hrp.IStep{ + hrp.NewStep("get with params"). + WithVariables(map[string]interface{}{ + "var1": "bar1", + "agent": "HttpRunnerPlus", + "expectedStatusCode": 200, + }). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "$var1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "$agent"}). + Extract(). + WithJmesPath("status_code", "statusCode"). + WithJmesPath("headers.\"Content-Type\"", "contentType"). + WithJmesPath("body.args.foo1", "varFoo1"). + Validate(). + AssertEqual("$statusCode", "$expectedStatusCode", "check status code"). // assert with extracted variable from current step + AssertEqual("$contentType", "application/json; charset=utf-8", "check header Content-Type"). // assert with extracted variable from current step + AssertEqual("$varFoo1", "bar1", "check args foo1"). // assert with extracted variable from current step + AssertEqual("body.args.foo2", "bar2", "check args foo2"). + AssertEqual("body.headers.\"user-agent\"", "HttpRunnerPlus", "check header user agent"), + }, + } + + err := hrp.NewRunner(t).Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} + +// reference extracted variables from previous step +func TestCaseExtractStepAssociation(t *testing.T) { + testcase := &hrp.TestCase{ + Config: hrp.NewConfig("run request with variables"). + SetBaseURL("https://postman-echo.com"). + SetVerifySSL(false), + TestSteps: []hrp.IStep{ + hrp.NewStep("get with params"). + WithVariables(map[string]interface{}{ + "var1": "bar1", + "agent": "HttpRunnerPlus", + }). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "$var1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "$agent"}). + Extract(). + WithJmesPath("status_code", "statusCode"). + WithJmesPath("headers.\"Content-Type\"", "contentType"). + WithJmesPath("body.args.foo1", "varFoo1"). + Validate(). + AssertEqual("$statusCode", 200, "check status code"). + AssertEqual("$contentType", "application/json; charset=utf-8", "check header Content-Type"). + AssertEqual("$varFoo1", "bar1", "check args foo1"). + AssertEqual("body.args.foo2", "bar2", "check args foo2"). + AssertEqual("body.headers.\"user-agent\"", "HttpRunnerPlus", "check header user agent"), + hrp.NewStep("post json data"). + POST("/post"). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). + WithBody(map[string]interface{}{"foo1": "bar1", "foo2": "bar2"}). + Validate(). + AssertEqual("status_code", "$statusCode", "check status code"). // assert with extracted variable from previous step + AssertEqual("$varFoo1", "bar1", "check json foo1"). // assert with extracted variable from previous step + AssertEqual("body.json.foo2", "bar2", "check json foo2"), + }, + } + + err := hrp.NewRunner(t).Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} diff --git a/examples/hrp/function_test.go b/examples/hrp/function_test.go new file mode 100644 index 00000000..0c2e8106 --- /dev/null +++ b/examples/hrp/function_test.go @@ -0,0 +1,49 @@ +package examples + +import ( + "testing" + + "github.com/httprunner/httprunner/hrp" +) + +func TestCaseCallFunction(t *testing.T) { + testcase := &hrp.TestCase{ + Config: hrp.NewConfig("run request with functions"). + SetBaseURL("https://postman-echo.com"). + WithVariables(map[string]interface{}{ + "n": 5, + "a": 12.3, + "b": 3.45, + }). + SetVerifySSL(false), + TestSteps: []hrp.IStep{ + hrp.NewStep("get with params"). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "${gen_random_string($n)}", "foo2": "${max($a, $b)}", "foo3": "Foo3"}). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). + Extract(). + WithJmesPath("body.args.foo1", "varFoo1"). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.args.foo1", 5, "check args foo1"). + AssertEqual("body.args.foo2", "12.3", "check args foo2"). + AssertTypeMatch("body.args.foo3", "str", "check args foo3 is type string"). + AssertStringEqual("body.args.foo3", "foo3", "check args foo3 case-insensitivity"). + AssertContains("body.args.foo3", "Foo", "check contains "). + AssertContainedBy("body.args.foo3", "this is Foo3 test", "check contained by"), // notice: request params value will be converted to string + hrp.NewStep("post json data with functions"). + POST("/post"). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). + WithBody(map[string]interface{}{"foo1": "${gen_random_string($n)}", "foo2": "${max($a, $b)}"}). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.json.foo1", 5, "check args foo1"). + AssertEqual("body.json.foo2", 12.3, "check args foo2"), + }, + } + + err := hrp.NewRunner(t).Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} diff --git a/examples/hrp/har/demo.har b/examples/hrp/har/demo.har new file mode 100644 index 00000000..3a94a304 --- /dev/null +++ b/examples/hrp/har/demo.har @@ -0,0 +1,356 @@ +{ + "log": { + "version": "1.2", + "creator": { + "name": "Charles Proxy", + "version": "4.6.1" + }, + "entries": [ + { + "startedDateTime": "2021-10-15T20:29:14.396+08:00", + "time": 1528, + "request": { + "method": "GET", + "url": "https://postman-echo.com/get?foo1=HDnY8&foo2=34.5", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "User-Agent", + "value": "HttpRunnerPlus" + }, + { + "name": "Accept-Encoding", + "value": "gzip" + } + ], + "queryString": [ + { + "name": "foo1", + "value": "HDnY8" + }, + { + "name": "foo2", + "value": "34.5" + } + ], + "headersSize": 113, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3Az_LpglkKxTvJ_eHVUH6V67drKp0AGWW-.PidabaXOnatLRP47hVyqqepl6BdrpEQzRlJQXtbIiwk", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Fri, 15 Oct 2021 12:29:15 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "300" + }, + { + "name": "ETag", + "value": "W/\"12c-1pyB4v4mv3hdBoU+8cUmx4p37qI\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3Az_LpglkKxTvJ_eHVUH6V67drKp0AGWW-.PidabaXOnatLRP47hVyqqepl6BdrpEQzRlJQXtbIiwk; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 300, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhcmdzIjp7ImZvbzEiOiJIRG5ZOCIsImZvbzIiOiIzNC41In0sImhlYWRlcnMiOnsieC1mb3J3YXJkZWQtcHJvdG8iOiJodHRwcyIsIngtZm9yd2FyZGVkLXBvcnQiOiI0NDMiLCJob3N0IjoicG9zdG1hbi1lY2hvLmNvbSIsIngtYW16bi10cmFjZS1pZCI6IlJvb3Q9MS02MTY5NzQxYi01YjgyNTRjZTZjZThlNTU2NTRiNzc3MmQiLCJ1c2VyLWFnZW50IjoiSHR0cEJvb21lciIsImFjY2VwdC1lbmNvZGluZyI6Imd6aXAifSwidXJsIjoiaHR0cHM6Ly9wb3N0bWFuLWVjaG8uY29tL2dldD9mb28xPUhEblk4JmZvbzI9MzQuNSJ9", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 300 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 105, + "connect": 1108, + "ssl": 721, + "send": 1, + "wait": 312, + "receive": 2 + } + }, + { + "startedDateTime": "2021-10-15T20:29:16.120+08:00", + "time": 306, + "request": { + "method": "POST", + "url": "https://postman-echo.com/post", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3Az_LpglkKxTvJ_eHVUH6V67drKp0AGWW-.PidabaXOnatLRP47hVyqqepl6BdrpEQzRlJQXtbIiwk" + } + ], + "headers": [ + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "User-Agent", + "value": "Go-http-client/1.1" + }, + { + "name": "Content-Length", + "value": "28" + }, + { + "name": "Content-Type", + "value": "application/json; charset=UTF-8" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3Az_LpglkKxTvJ_eHVUH6V67drKp0AGWW-.PidabaXOnatLRP47hVyqqepl6BdrpEQzRlJQXtbIiwk" + }, + { + "name": "Accept-Encoding", + "value": "gzip" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/json; charset=UTF-8", + "text": "{\"foo1\":\"HDnY8\",\"foo2\":12.3}" + }, + "headersSize": 269, + "bodySize": 28 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AS5e7w0zQ0xAsCwh9L8T6R7QLYCO7_gtD.r8%2B2w9IWqEIfuVkrZjnxzm2xADIk34zKAWXRPapr%2FAw", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Fri, 15 Oct 2021 12:29:16 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "526" + }, + { + "name": "ETag", + "value": "W/\"20e-aXqJ0H6Q30sU41c/D7asB+yXWeQ\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AS5e7w0zQ0xAsCwh9L8T6R7QLYCO7_gtD.r8%2B2w9IWqEIfuVkrZjnxzm2xADIk34zKAWXRPapr%2FAw; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 526, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhcmdzIjp7fSwiZGF0YSI6eyJmb28xIjoiSERuWTgiLCJmb28yIjoxMi4zfSwiZmlsZXMiOnt9LCJmb3JtIjp7fSwiaGVhZGVycyI6eyJ4LWZvcndhcmRlZC1wcm90byI6Imh0dHBzIiwieC1mb3J3YXJkZWQtcG9ydCI6IjQ0MyIsImhvc3QiOiJwb3N0bWFuLWVjaG8uY29tIiwieC1hbXpuLXRyYWNlLWlkIjoiUm9vdD0xLTYxNjk3NDFjLTIxN2RiMGI3MWFkYjgwYmQ3ODUxOTI2OCIsImNvbnRlbnQtbGVuZ3RoIjoiMjgiLCJ1c2VyLWFnZW50IjoiR28taHR0cC1jbGllbnQvMS4xIiwiY29udGVudC10eXBlIjoiYXBwbGljYXRpb24vanNvbjsgY2hhcnNldD1VVEYtOCIsImNvb2tpZSI6InNhaWxzLnNpZD1zJTNBel9McGdsa0t4VHZKX2VIVlVINlY2N2RyS3AwQUdXVy0uUGlkYWJhWE9uYXRMUlA0N2hWeXFxZXBsNkJkcnBFUXpSbEpRWHRiSWl3ayIsImFjY2VwdC1lbmNvZGluZyI6Imd6aXAifSwianNvbiI6eyJmb28xIjoiSERuWTgiLCJmb28yIjoxMi4zfSwidXJsIjoiaHR0cHM6Ly9wb3N0bWFuLWVjaG8uY29tL3Bvc3QifQ==", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 526 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": -1, + "connect": -1, + "ssl": -1, + "send": 1, + "wait": 304, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-15T20:29:16.427+08:00", + "time": 305, + "request": { + "method": "POST", + "url": "https://postman-echo.com/post", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AS5e7w0zQ0xAsCwh9L8T6R7QLYCO7_gtD.r8%2B2w9IWqEIfuVkrZjnxzm2xADIk34zKAWXRPapr%2FAw" + } + ], + "headers": [ + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "User-Agent", + "value": "Go-http-client/1.1" + }, + { + "name": "Content-Length", + "value": "20" + }, + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded; charset=UTF-8" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AS5e7w0zQ0xAsCwh9L8T6R7QLYCO7_gtD.r8%2B2w9IWqEIfuVkrZjnxzm2xADIk34zKAWXRPapr%2FAw" + }, + { + "name": "Accept-Encoding", + "value": "gzip" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/x-www-form-urlencoded; charset=UTF-8", + "params": [ + { + "name": "foo1", + "value": "HDnY8" + }, + { + "name": "foo2", + "value": "12.3" + } + ] + }, + "headersSize": 290, + "bodySize": 20 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AMp2gGgeCCDM4sRS_MfL1q-hAkL3bAk84.9XT7TTW8QzueQqtQ6bQM%2BgHqiUBbkJSfgM5CbfhFreQ", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Fri, 15 Oct 2021 12:29:16 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "551" + }, + { + "name": "ETag", + "value": "W/\"227-micuvGYwtEZN542D1sTL0hAZaRs\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AMp2gGgeCCDM4sRS_MfL1q-hAkL3bAk84.9XT7TTW8QzueQqtQ6bQM%2BgHqiUBbkJSfgM5CbfhFreQ; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 551, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhcmdzIjp7fSwiZGF0YSI6IiIsImZpbGVzIjp7fSwiZm9ybSI6eyJmb28xIjoiSERuWTgiLCJmb28yIjoiMTIuMyJ9LCJoZWFkZXJzIjp7IngtZm9yd2FyZGVkLXByb3RvIjoiaHR0cHMiLCJ4LWZvcndhcmRlZC1wb3J0IjoiNDQzIiwiaG9zdCI6InBvc3RtYW4tZWNoby5jb20iLCJ4LWFtem4tdHJhY2UtaWQiOiJSb290PTEtNjE2OTc0MWMtNWI5ZDEyMWI2N2FlZTI0MTUyMmQzMjE2IiwiY29udGVudC1sZW5ndGgiOiIyMCIsInVzZXItYWdlbnQiOiJHby1odHRwLWNsaWVudC8xLjEiLCJjb250ZW50LXR5cGUiOiJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQ7IGNoYXJzZXQ9VVRGLTgiLCJjb29raWUiOiJzYWlscy5zaWQ9cyUzQVM1ZTd3MHpRMHhBc0N3aDlMOFQ2UjdRTFlDTzdfZ3RELnI4JTJCMnc5SVdxRUlmdVZrclpqbnh6bTJ4QURJazM0ektBV1hSUGFwciUyRkF3IiwiYWNjZXB0LWVuY29kaW5nIjoiZ3ppcCJ9LCJqc29uIjp7ImZvbzEiOiJIRG5ZOCIsImZvbzIiOiIxMi4zIn0sInVybCI6Imh0dHBzOi8vcG9zdG1hbi1lY2hvLmNvbS9wb3N0In0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 551 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": -1, + "connect": -1, + "ssl": -1, + "send": 0, + "wait": 303, + "receive": 2 + } + } + ] + } +} \ No newline at end of file diff --git a/examples/hrp/har/demo.json b/examples/hrp/har/demo.json new file mode 100644 index 00000000..292ad513 --- /dev/null +++ b/examples/hrp/har/demo.json @@ -0,0 +1,128 @@ +{ + "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/hrp/har/postman-echo.har b/examples/hrp/har/postman-echo.har new file mode 100644 index 00000000..362055a2 --- /dev/null +++ b/examples/hrp/har/postman-echo.har @@ -0,0 +1,4694 @@ +{ + "log": { + "version": "1.2", + "creator": { + "name": "Charles Proxy", + "version": "4.6.1" + }, + "entries": [ + { + "startedDateTime": "2021-10-16T15:04:52.736+08:00", + "time": 1763, + "request": { + "method": "GET", + "url": "https://postman-echo.com/get?foo1=bar1&foo2=bar2", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3ASAXM8INphoz4_-5nCeQNBtrlsWuHs5Mt.83PsbOXUZUoPolzR2vpghXLUghDPLyA3NSrVKI8A8ws" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "ea19464c-ddd4-4724-abe9-5e2b254c2723" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3ASAXM8INphoz4_-5nCeQNBtrlsWuHs5Mt.83PsbOXUZUoPolzR2vpghXLUghDPLyA3NSrVKI8A8ws" + } + ], + "queryString": [ + { + "name": "foo1", + "value": "bar1" + }, + { + "name": "foo2", + "value": "bar2" + } + ], + "headersSize": 351, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3Ack89N2nb1AxU-T-nxvJrvOS1KvUXbiU2.3nAhh%2FjA%2F%2FNvHtWI8NApXa1QWV3hDD6LBsfUwpIdYQc", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:04:54 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "521" + }, + { + "name": "ETag", + "value": "W/\"209-bxFKtsTdtFVMiL0IMqJLfvcJtAI\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3Ack89N2nb1AxU-T-nxvJrvOS1KvUXbiU2.3nAhh%2FjA%2F%2FNvHtWI8NApXa1QWV3hDD6LBsfUwpIdYQc; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 521, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhcmdzIjp7ImZvbzEiOiJiYXIxIiwiZm9vMiI6ImJhcjIifSwiaGVhZGVycyI6eyJ4LWZvcndhcmRlZC1wcm90byI6Imh0dHBzIiwieC1mb3J3YXJkZWQtcG9ydCI6IjQ0MyIsImhvc3QiOiJwb3N0bWFuLWVjaG8uY29tIiwieC1hbXpuLXRyYWNlLWlkIjoiUm9vdD0xLTYxNmE3OTk2LTZhZjVmMWMzMjc2YmI5ZjI0NjczOGFmZSIsInVzZXItYWdlbnQiOiJQb3N0bWFuUnVudGltZS83LjI4LjQiLCJhY2NlcHQiOiIqLyoiLCJjYWNoZS1jb250cm9sIjoibm8tY2FjaGUiLCJwb3N0bWFuLXRva2VuIjoiZWExOTQ2NGMtZGRkNC00NzI0LWFiZTktNWUyYjI1NGMyNzIzIiwiYWNjZXB0LWVuY29kaW5nIjoiZ3ppcCwgZGVmbGF0ZSwgYnIiLCJjb29raWUiOiJzYWlscy5zaWQ9cyUzQVNBWE04SU5waG96NF8tNW5DZVFOQnRybHNXdUhzNU10LjgzUHNiT1hVWlVvUG9selIydnBnaFhMVWdoRFBMeUEzTlNyVktJOEE4d3MifSwidXJsIjoiaHR0cHM6Ly9wb3N0bWFuLWVjaG8uY29tL2dldD9mb28xPWJhcjEmZm9vMj1iYXIyIn0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 521 + }, + "serverIPAddress": "52.7.241.1", + "cache": {}, + "timings": { + "dns": 489, + "connect": 950, + "ssl": 587, + "send": 0, + "wait": 324, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:04:54.568+08:00", + "time": 1131, + "request": { + "method": "POST", + "url": "https://postman-echo.com/post", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3Ack89N2nb1AxU-T-nxvJrvOS1KvUXbiU2.3nAhh%2FjA%2F%2FNvHtWI8NApXa1QWV3hDD6LBsfUwpIdYQc" + } + ], + "headers": [ + { + "name": "Content-Type", + "value": "text/plain" + }, + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "40756814-a974-4fcc-98d2-1f2aec73c295" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Content-Length", + "value": "58" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3Ack89N2nb1AxU-T-nxvJrvOS1KvUXbiU2.3nAhh%2FjA%2F%2FNvHtWI8NApXa1QWV3hDD6LBsfUwpIdYQc" + } + ], + "queryString": [], + "postData": { + "mimeType": "text/plain", + "text": "This is expected to be sent back as part of response body." + }, + "headersSize": 385, + "bodySize": 58 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A4bF7QNsgYKOBRnxJEclo-wiPIm6YxzFY.zmgnSBoVtZ3C40cBCJPsFS6KXTPoQBlKdS2FIdoxFaA", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:04:55 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "632" + }, + { + "name": "ETag", + "value": "W/\"278-Xov3AabKgpWo3NrcmrTPiUSe5vU\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3A4bF7QNsgYKOBRnxJEclo-wiPIm6YxzFY.zmgnSBoVtZ3C40cBCJPsFS6KXTPoQBlKdS2FIdoxFaA; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 632, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhcmdzIjp7fSwiZGF0YSI6IlRoaXMgaXMgZXhwZWN0ZWQgdG8gYmUgc2VudCBiYWNrIGFzIHBhcnQgb2YgcmVzcG9uc2UgYm9keS4iLCJmaWxlcyI6e30sImZvcm0iOnt9LCJoZWFkZXJzIjp7IngtZm9yd2FyZGVkLXByb3RvIjoiaHR0cHMiLCJ4LWZvcndhcmRlZC1wb3J0IjoiNDQzIiwiaG9zdCI6InBvc3RtYW4tZWNoby5jb20iLCJ4LWFtem4tdHJhY2UtaWQiOiJSb290PTEtNjE2YTc5OTctNmE1YTAwMTQ0ZjIyMmQ0MDNjYmUyZTcyIiwiY29udGVudC1sZW5ndGgiOiI1OCIsImNvbnRlbnQtdHlwZSI6InRleHQvcGxhaW4iLCJ1c2VyLWFnZW50IjoiUG9zdG1hblJ1bnRpbWUvNy4yOC40IiwiYWNjZXB0IjoiKi8qIiwiY2FjaGUtY29udHJvbCI6Im5vLWNhY2hlIiwicG9zdG1hbi10b2tlbiI6IjQwNzU2ODE0LWE5NzQtNGZjYy05OGQyLTFmMmFlYzczYzI5NSIsImFjY2VwdC1lbmNvZGluZyI6Imd6aXAsIGRlZmxhdGUsIGJyIiwiY29va2llIjoic2FpbHMuc2lkPXMlM0Fjazg5TjJuYjFBeFUtVC1ueHZKcnZPUzFLdlVYYmlVMi4zbkFoaCUyRmpBJTJGJTJGTnZIdFdJOE5BcFhhMVFXVjNoREQ2TEJzZlV3cElkWVFjIn0sImpzb24iOm51bGwsInVybCI6Imh0dHBzOi8vcG9zdG1hbi1lY2hvLmNvbS9wb3N0In0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 632 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 5, + "connect": 844, + "ssl": 566, + "send": 0, + "wait": 281, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:04:55.733+08:00", + "time": 1171, + "request": { + "method": "POST", + "url": "https://postman-echo.com/post", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A4bF7QNsgYKOBRnxJEclo-wiPIm6YxzFY.zmgnSBoVtZ3C40cBCJPsFS6KXTPoQBlKdS2FIdoxFaA" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "93843e50-2fe8-422d-b900-91095f9f0cdb" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "name": "Content-Length", + "value": "19" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3A4bF7QNsgYKOBRnxJEclo-wiPIm6YxzFY.zmgnSBoVtZ3C40cBCJPsFS6KXTPoQBlKdS2FIdoxFaA" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/x-www-form-urlencoded", + "params": [ + { + "name": "foo1", + "value": "bar1" + }, + { + "name": "foo2", + "value": "bar2" + } + ] + }, + "headersSize": 402, + "bodySize": 19 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A7Kp8q3TlXZgZpLiLQNE4OGvpaqJwWmWX.SkW6gD2iyLO%2FFZYMAbg0bTsfuHwnEBezprz6nbykPWg", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:04:57 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "643" + }, + { + "name": "ETag", + "value": "W/\"283-0tKS85K793Mxd9D1GST4xg1ZBfI\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3A7Kp8q3TlXZgZpLiLQNE4OGvpaqJwWmWX.SkW6gD2iyLO%2FFZYMAbg0bTsfuHwnEBezprz6nbykPWg; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 643, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhcmdzIjp7fSwiZGF0YSI6IiIsImZpbGVzIjp7fSwiZm9ybSI6eyJmb28xIjoiYmFyMSIsImZvbzIiOiJiYXIyIn0sImhlYWRlcnMiOnsieC1mb3J3YXJkZWQtcHJvdG8iOiJodHRwcyIsIngtZm9yd2FyZGVkLXBvcnQiOiI0NDMiLCJob3N0IjoicG9zdG1hbi1lY2hvLmNvbSIsIngtYW16bi10cmFjZS1pZCI6IlJvb3Q9MS02MTZhNzk5OS00NzcxNWU0ZjFhMjlmOGM1MmFmYTQyNDEiLCJjb250ZW50LWxlbmd0aCI6IjE5IiwidXNlci1hZ2VudCI6IlBvc3RtYW5SdW50aW1lLzcuMjguNCIsImFjY2VwdCI6IiovKiIsImNhY2hlLWNvbnRyb2wiOiJuby1jYWNoZSIsInBvc3RtYW4tdG9rZW4iOiI5Mzg0M2U1MC0yZmU4LTQyMmQtYjkwMC05MTA5NWY5ZjBjZGIiLCJhY2NlcHQtZW5jb2RpbmciOiJnemlwLCBkZWZsYXRlLCBiciIsImNvbnRlbnQtdHlwZSI6ImFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZCIsImNvb2tpZSI6InNhaWxzLnNpZD1zJTNBNGJGN1FOc2dZS09CUm54SkVjbG8td2lQSW02WXh6Rlkuem1nblNCb1Z0WjNDNDBjQkNKUHNGUzZLWFRQb1FCbEtkUzJGSWRveEZhQSJ9LCJqc29uIjp7ImZvbzEiOiJiYXIxIiwiZm9vMiI6ImJhcjIifSwidXJsIjoiaHR0cHM6Ly9wb3N0bWFuLWVjaG8uY29tL3Bvc3QifQ==", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 643 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 883, + "ssl": 571, + "send": 0, + "wait": 286, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:04:56.938+08:00", + "time": 1458, + "request": { + "method": "PUT", + "url": "https://postman-echo.com/put", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A7Kp8q3TlXZgZpLiLQNE4OGvpaqJwWmWX.SkW6gD2iyLO%2FFZYMAbg0bTsfuHwnEBezprz6nbykPWg" + } + ], + "headers": [ + { + "name": "Content-Type", + "value": "text/plain" + }, + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "5d357b2b-0f10-4ded-bc9a-299ebef7a2d5" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Content-Length", + "value": "58" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3A7Kp8q3TlXZgZpLiLQNE4OGvpaqJwWmWX.SkW6gD2iyLO%2FFZYMAbg0bTsfuHwnEBezprz6nbykPWg" + } + ], + "queryString": [], + "postData": { + "mimeType": "text/plain", + "text": "This is expected to be sent back as part of response body." + }, + "headersSize": 379, + "bodySize": 58 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3ArMIVJXM1u78IGSzps0LYNjimloLEMdqk.6bzxgShLW4DTNlqRdZREK7OUV1kqu2kMHtEVxR9Xlyg", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:04:58 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "627" + }, + { + "name": "ETag", + "value": "W/\"273-pgr4Cuw5RxQ81uMoLc1tfZ0rHvc\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3ArMIVJXM1u78IGSzps0LYNjimloLEMdqk.6bzxgShLW4DTNlqRdZREK7OUV1kqu2kMHtEVxR9Xlyg; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 627, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhcmdzIjp7fSwiZGF0YSI6IlRoaXMgaXMgZXhwZWN0ZWQgdG8gYmUgc2VudCBiYWNrIGFzIHBhcnQgb2YgcmVzcG9uc2UgYm9keS4iLCJmaWxlcyI6e30sImZvcm0iOnt9LCJoZWFkZXJzIjp7IngtZm9yd2FyZGVkLXByb3RvIjoiaHR0cHMiLCJ4LWZvcndhcmRlZC1wb3J0IjoiNDQzIiwiaG9zdCI6InBvc3RtYW4tZWNoby5jb20iLCJ4LWFtem4tdHJhY2UtaWQiOiJSb290PTEtNjE2YTc5OWEtMjNiYzAyNGYwNWY5YTc5OTYwZTY4OWI3IiwiY29udGVudC1sZW5ndGgiOiI1OCIsImNvbnRlbnQtdHlwZSI6InRleHQvcGxhaW4iLCJ1c2VyLWFnZW50IjoiUG9zdG1hblJ1bnRpbWUvNy4yOC40IiwiYWNjZXB0IjoiKi8qIiwiY2FjaGUtY29udHJvbCI6Im5vLWNhY2hlIiwicG9zdG1hbi10b2tlbiI6IjVkMzU3YjJiLTBmMTAtNGRlZC1iYzlhLTI5OWViZWY3YTJkNSIsImFjY2VwdC1lbmNvZGluZyI6Imd6aXAsIGRlZmxhdGUsIGJyIiwiY29va2llIjoic2FpbHMuc2lkPXMlM0E3S3A4cTNUbFhaZ1pwTGlMUU5FNE9HdnBhcUp3V21XWC5Ta1c2Z0QyaXlMTyUyRkZaWU1BYmcwYlRzZnVId25FQmV6cHJ6Nm5ieWtQV2cifSwianNvbiI6bnVsbCwidXJsIjoiaHR0cHM6Ly9wb3N0bWFuLWVjaG8uY29tL3B1dCJ9", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 627 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 1056, + "ssl": 717, + "send": 0, + "wait": 400, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:04:58.430+08:00", + "time": 1130, + "request": { + "method": "PATCH", + "url": "https://postman-echo.com/patch", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3ArMIVJXM1u78IGSzps0LYNjimloLEMdqk.6bzxgShLW4DTNlqRdZREK7OUV1kqu2kMHtEVxR9Xlyg" + } + ], + "headers": [ + { + "name": "Content-Type", + "value": "text/plain" + }, + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "27a30a79-5d88-43c0-8c83-fce5bb585729" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Content-Length", + "value": "58" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3ArMIVJXM1u78IGSzps0LYNjimloLEMdqk.6bzxgShLW4DTNlqRdZREK7OUV1kqu2kMHtEVxR9Xlyg" + } + ], + "queryString": [], + "postData": { + "mimeType": "text/plain", + "text": "This is expected to be sent back as part of response body." + }, + "headersSize": 381, + "bodySize": 58 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AlTv3pBzULeMHqjWpJWW-rwLZYYdqzSyW.J5YSZCf1unKehq5zNyuee%2B2xYkqoK%2BcTPTr3RzHYtYM", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:04:59 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "627" + }, + { + "name": "ETag", + "value": "W/\"273-AGMTrKyVT6uBRPjNxv5QoHk04dc\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AlTv3pBzULeMHqjWpJWW-rwLZYYdqzSyW.J5YSZCf1unKehq5zNyuee%2B2xYkqoK%2BcTPTr3RzHYtYM; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 627, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhcmdzIjp7fSwiZGF0YSI6IlRoaXMgaXMgZXhwZWN0ZWQgdG8gYmUgc2VudCBiYWNrIGFzIHBhcnQgb2YgcmVzcG9uc2UgYm9keS4iLCJmaWxlcyI6e30sImZvcm0iOnt9LCJoZWFkZXJzIjp7IngtZm9yd2FyZGVkLXByb3RvIjoiaHR0cHMiLCJ4LWZvcndhcmRlZC1wb3J0IjoiNDQzIiwiaG9zdCI6InBvc3RtYW4tZWNoby5jb20iLCJ4LWFtem4tdHJhY2UtaWQiOiJSb290PTEtNjE2YTc5OWItNjhhOTBmMWM0ZDU5NGUwMzUxZDhlNjIwIiwiY29udGVudC1sZW5ndGgiOiI1OCIsImNvbnRlbnQtdHlwZSI6InRleHQvcGxhaW4iLCJ1c2VyLWFnZW50IjoiUG9zdG1hblJ1bnRpbWUvNy4yOC40IiwiYWNjZXB0IjoiKi8qIiwiY2FjaGUtY29udHJvbCI6Im5vLWNhY2hlIiwicG9zdG1hbi10b2tlbiI6IjI3YTMwYTc5LTVkODgtNDNjMC04YzgzLWZjZTViYjU4NTcyOSIsImFjY2VwdC1lbmNvZGluZyI6Imd6aXAsIGRlZmxhdGUsIGJyIiwiY29va2llIjoic2FpbHMuc2lkPXMlM0FyTUlWSlhNMXU3OElHU3pwczBMWU5qaW1sb0xFTWRxay42Ynp4Z1NoTFc0RFRObHFSZFpSRUs3T1VWMWtxdTJrTUh0RVZ4UjlYbHlnIn0sImpzb24iOm51bGwsInVybCI6Imh0dHBzOi8vcG9zdG1hbi1lY2hvLmNvbS9wYXRjaCJ9", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 627 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 846, + "ssl": 567, + "send": 0, + "wait": 282, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:04:59.597+08:00", + "time": 1261, + "request": { + "method": "DELETE", + "url": "https://postman-echo.com/delete", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AlTv3pBzULeMHqjWpJWW-rwLZYYdqzSyW.J5YSZCf1unKehq5zNyuee%2B2xYkqoK%2BcTPTr3RzHYtYM" + } + ], + "headers": [ + { + "name": "Content-Type", + "value": "text/plain" + }, + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "b11f7819-4c39-41b3-9d06-696b38c3e515" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Content-Length", + "value": "58" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AlTv3pBzULeMHqjWpJWW-rwLZYYdqzSyW.J5YSZCf1unKehq5zNyuee%2B2xYkqoK%2BcTPTr3RzHYtYM" + } + ], + "queryString": [], + "postData": { + "mimeType": "text/plain", + "text": "This is expected to be sent back as part of response body." + }, + "headersSize": 387, + "bodySize": 58 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A6Sj7Mduyb72fC-X0OQbDmFqp77bVEgt8.b5X8H%2BtACzKfkUlH%2FBtSYH%2FdSQ5fHynzHjK8gE3s%2FpI", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:00 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "632" + }, + { + "name": "ETag", + "value": "W/\"278-oXQWd2iRqZbkTY58xndHHEJ9sVo\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3A6Sj7Mduyb72fC-X0OQbDmFqp77bVEgt8.b5X8H%2BtACzKfkUlH%2FBtSYH%2FdSQ5fHynzHjK8gE3s%2FpI; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 632, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhcmdzIjp7fSwiZGF0YSI6IlRoaXMgaXMgZXhwZWN0ZWQgdG8gYmUgc2VudCBiYWNrIGFzIHBhcnQgb2YgcmVzcG9uc2UgYm9keS4iLCJmaWxlcyI6e30sImZvcm0iOnt9LCJoZWFkZXJzIjp7IngtZm9yd2FyZGVkLXByb3RvIjoiaHR0cHMiLCJ4LWZvcndhcmRlZC1wb3J0IjoiNDQzIiwiaG9zdCI6InBvc3RtYW4tZWNoby5jb20iLCJ4LWFtem4tdHJhY2UtaWQiOiJSb290PTEtNjE2YTc5OWMtNzdhNjA3NzI2ODIxZjc4ODcxNjU2MmZkIiwiY29udGVudC1sZW5ndGgiOiI1OCIsImNvbnRlbnQtdHlwZSI6InRleHQvcGxhaW4iLCJ1c2VyLWFnZW50IjoiUG9zdG1hblJ1bnRpbWUvNy4yOC40IiwiYWNjZXB0IjoiKi8qIiwiY2FjaGUtY29udHJvbCI6Im5vLWNhY2hlIiwicG9zdG1hbi10b2tlbiI6ImIxMWY3ODE5LTRjMzktNDFiMy05ZDA2LTY5NmIzOGMzZTUxNSIsImFjY2VwdC1lbmNvZGluZyI6Imd6aXAsIGRlZmxhdGUsIGJyIiwiY29va2llIjoic2FpbHMuc2lkPXMlM0FsVHYzcEJ6VUxlTUhxaldwSldXLXJ3TFpZWWRxelN5Vy5KNVlTWkNmMXVuS2VocTV6Tnl1ZWUlMkIyeFlrcW9LJTJCY1RQVHIzUnpIWXRZTSJ9LCJqc29uIjpudWxsLCJ1cmwiOiJodHRwczovL3Bvc3RtYW4tZWNoby5jb20vZGVsZXRlIn0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 632 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 930, + "ssl": 649, + "send": 0, + "wait": 330, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:00.891+08:00", + "time": 1236, + "request": { + "method": "GET", + "url": "https://postman-echo.com/headers", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A6Sj7Mduyb72fC-X0OQbDmFqp77bVEgt8.b5X8H%2BtACzKfkUlH%2FBtSYH%2FdSQ5fHynzHjK8gE3s%2FpI" + } + ], + "headers": [ + { + "name": "my-sample-header", + "value": "Lorem ipsum dolor sit amet" + }, + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "1a4e2039-d29b-4ed7-89e9-584b354246be" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3A6Sj7Mduyb72fC-X0OQbDmFqp77bVEgt8.b5X8H%2BtACzKfkUlH%2FBtSYH%2FdSQ5fHynzHjK8gE3s%2FpI" + } + ], + "queryString": [], + "headersSize": 389, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AvvP5l4Bk7WCLBU9LNXalNk4w4x3Q_2Zi.JiGgykR8RlAGIdRWv%2FdCmCL0Tbmwyni9KkXXgnzn59s", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:02 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "483" + }, + { + "name": "ETag", + "value": "W/\"1e3-vZgcezgrti0mSOuFJY4fsdEQ6aE\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AvvP5l4Bk7WCLBU9LNXalNk4w4x3Q_2Zi.JiGgykR8RlAGIdRWv%2FdCmCL0Tbmwyni9KkXXgnzn59s; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 483, + "mimeType": "application/json; charset=utf-8", + "text": "eyJoZWFkZXJzIjp7IngtZm9yd2FyZGVkLXByb3RvIjoiaHR0cHMiLCJ4LWZvcndhcmRlZC1wb3J0IjoiNDQzIiwiaG9zdCI6InBvc3RtYW4tZWNoby5jb20iLCJ4LWFtem4tdHJhY2UtaWQiOiJSb290PTEtNjE2YTc5OWUtNjY1MjlmNGExNjkxM2U0YTY2YThiZmM3IiwibXktc2FtcGxlLWhlYWRlciI6IkxvcmVtIGlwc3VtIGRvbG9yIHNpdCBhbWV0IiwidXNlci1hZ2VudCI6IlBvc3RtYW5SdW50aW1lLzcuMjguNCIsImFjY2VwdCI6IiovKiIsImNhY2hlLWNvbnRyb2wiOiJuby1jYWNoZSIsInBvc3RtYW4tdG9rZW4iOiIxYTRlMjAzOS1kMjliLTRlZDctODllOS01ODRiMzU0MjQ2YmUiLCJhY2NlcHQtZW5jb2RpbmciOiJnemlwLCBkZWZsYXRlLCBiciIsImNvb2tpZSI6InNhaWxzLnNpZD1zJTNBNlNqN01kdXliNzJmQy1YME9RYkRtRnFwNzdiVkVndDguYjVYOEglMkJ0QUN6S2ZrVWxIJTJGQnRTWUglMkZkU1E1Zkh5bnpIaks4Z0UzcyUyRnBJIn19", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 483 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 949, + "ssl": 571, + "send": 0, + "wait": 286, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:02.177+08:00", + "time": 1543, + "request": { + "method": "GET", + "url": "https://postman-echo.com/response-headers?foo1=bar1&foo2=bar2", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AvvP5l4Bk7WCLBU9LNXalNk4w4x3Q_2Zi.JiGgykR8RlAGIdRWv%2FdCmCL0Tbmwyni9KkXXgnzn59s" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "b00d3c25-a84b-4152-bcf8-4c573c06024b" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AvvP5l4Bk7WCLBU9LNXalNk4w4x3Q_2Zi.JiGgykR8RlAGIdRWv%2FdCmCL0Tbmwyni9KkXXgnzn59s" + } + ], + "queryString": [ + { + "name": "foo1", + "value": "bar1" + }, + { + "name": "foo2", + "value": "bar2" + } + ], + "headersSize": 366, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3APA71Iib2-7KqjRMajldmUsDqOqmRDB6-.zpTeobSmlq81Z7R%2FyL7q3o8%2FAP0tfOOZSPQdBlirJ6g", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:03 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "29" + }, + { + "name": "foo1", + "value": "bar1" + }, + { + "name": "foo2", + "value": "bar2" + }, + { + "name": "ETag", + "value": "W/\"1d-PgOLWVqd2mMvcpNzTF0Cfy4hftg\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3APA71Iib2-7KqjRMajldmUsDqOqmRDB6-.zpTeobSmlq81Z7R%2FyL7q3o8%2FAP0tfOOZSPQdBlirJ6g; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 29, + "mimeType": "application/json; charset=utf-8", + "text": "eyJmb28xIjoiYmFyMSIsImZvbzIiOiJiYXIyIn0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 29 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 1140, + "ssl": 811, + "send": 0, + "wait": 402, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:03.761+08:00", + "time": 1174, + "request": { + "method": "GET", + "url": "https://postman-echo.com/basic-auth", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3APA71Iib2-7KqjRMajldmUsDqOqmRDB6-.zpTeobSmlq81Z7R%2FyL7q3o8%2FAP0tfOOZSPQdBlirJ6g" + } + ], + "headers": [ + { + "name": "Authorization", + "value": "Basic cG9zdG1hbjpwYXNzd29yZA==" + }, + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "d9f810a2-292d-41c4-95e1-ec9f9ae778d6" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3APA71Iib2-7KqjRMajldmUsDqOqmRDB6-.zpTeobSmlq81Z7R%2FyL7q3o8%2FAP0tfOOZSPQdBlirJ6g" + } + ], + "queryString": [], + "headersSize": 389, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AT2IbNG9nLojvklvDr1mo2cCftGUgcAgU.f1XqnM5ebKiLtIs3CKYYvBo7j5iHwiP9EuG9i91RR%2FU", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:05 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "22" + }, + { + "name": "ETag", + "value": "W/\"16-sJz8uwjdDv0wvm7//BYdNw8vMbU\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AT2IbNG9nLojvklvDr1mo2cCftGUgcAgU.f1XqnM5ebKiLtIs3CKYYvBo7j5iHwiP9EuG9i91RR%2FU; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 22, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhdXRoZW50aWNhdGVkIjp0cnVlfQ==", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 22 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 2, + "connect": 886, + "ssl": 607, + "send": 0, + "wait": 286, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:04.977+08:00", + "time": 1201, + "request": { + "method": "GET", + "url": "https://postman-echo.com/digest-auth", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AT2IbNG9nLojvklvDr1mo2cCftGUgcAgU.f1XqnM5ebKiLtIs3CKYYvBo7j5iHwiP9EuG9i91RR%2FU" + } + ], + "headers": [ + { + "name": "Authorization", + "value": "Digest username=\"postman\", realm=\"Users\", nonce=\"W7kT5VowsR0pcTfL9fTwZKv2tRdEiG6c\", uri=\"/digest-auth\", algorithm=\"MD5\", response=\"bab1b1e6534f84b43e9deb17bca9371b\"" + }, + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "42e8340a-852b-4c7a-ab7d-d0b027f044ca" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AT2IbNG9nLojvklvDr1mo2cCftGUgcAgU.f1XqnM5ebKiLtIs3CKYYvBo7j5iHwiP9EuG9i91RR%2FU" + } + ], + "queryString": [], + "headersSize": 522, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AWyHRwAoLc64u8sF_LqU0BUYAieEguHiH.gb%2BNYX72g6n5lHjLdl5K1hsKmLHYJUwoOwKkDWVl7qY", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:06 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "22" + }, + { + "name": "ETag", + "value": "W/\"16-sJz8uwjdDv0wvm7//BYdNw8vMbU\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AWyHRwAoLc64u8sF_LqU0BUYAieEguHiH.gb%2BNYX72g6n5lHjLdl5K1hsKmLHYJUwoOwKkDWVl7qY; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 22, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhdXRoZW50aWNhdGVkIjp0cnVlfQ==", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 22 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 857, + "ssl": 571, + "send": 0, + "wait": 342, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:06.216+08:00", + "time": 1601, + "request": { + "method": "GET", + "url": "https://postman-echo.com/auth/hawk", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AWyHRwAoLc64u8sF_LqU0BUYAieEguHiH.gb%2BNYX72g6n5lHjLdl5K1hsKmLHYJUwoOwKkDWVl7qY" + } + ], + "headers": [ + { + "name": "Authorization", + "value": "Hawk id=\"dh37fgj492je\", ts=\"1634367906\", nonce=\"RZKGNz\", mac=\"EASK1an/9fmDhFJcqH8XE4pTuUaSJisuQVM+NCOjNlM=\"" + }, + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "46645864-583c-446b-9d36-9610fb114d99" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AWyHRwAoLc64u8sF_LqU0BUYAieEguHiH.gb%2BNYX72g6n5lHjLdl5K1hsKmLHYJUwoOwKkDWVl7qY" + } + ], + "queryString": [], + "headersSize": 463, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AZQRuQaIb28umtrzP-HOj4fSqeag88Pvj.KVLylhlYJ3JKMHUS0UVeLCT6qRcBgQl%2BM14UxI7EgQs", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:07 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "44" + }, + { + "name": "ETag", + "value": "W/\"2c-UZ5QLCWp1r9bxkKdVTupq1/XxUI\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AZQRuQaIb28umtrzP-HOj4fSqeag88Pvj.KVLylhlYJ3JKMHUS0UVeLCT6qRcBgQl%2BM14UxI7EgQs; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 44, + "mimeType": "application/json; charset=utf-8", + "text": "eyJtZXNzYWdlIjoiSGF3ayBBdXRoZW50aWNhdGlvbiBTdWNjZXNzZnVsIn0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 44 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 1196, + "ssl": 915, + "send": 0, + "wait": 403, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:07.866+08:00", + "time": 1196, + "request": { + "method": "GET", + "url": "https://postman-echo.com/oauth1", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AZQRuQaIb28umtrzP-HOj4fSqeag88Pvj.KVLylhlYJ3JKMHUS0UVeLCT6qRcBgQl%2BM14UxI7EgQs" + } + ], + "headers": [ + { + "name": "Authorization", + "value": "OAuth oauth_consumer_key=\"RKCGzna7bv9YD57c\",oauth_signature_method=\"HMAC-SHA1\",oauth_timestamp=\"1634367907\",oauth_nonce=\"pAoTV0k5VZa\",oauth_signature=\"ZTkfsaUA1B2s7kyl3HaFm1zFow4%3D\"" + }, + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "3d9db9bb-5bcf-425e-b0e4-a958c07d7969" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AZQRuQaIb28umtrzP-HOj4fSqeag88Pvj.KVLylhlYJ3JKMHUS0UVeLCT6qRcBgQl%2BM14UxI7EgQs" + } + ], + "queryString": [], + "headersSize": 535, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AsdmvN2_ZNE0YlwQY5GxY04ptWTOYR5NU.kkH0dnWlEMsblzPMurLX8nsQRRbRqLqteIhA0621onY", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:09 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "78" + }, + { + "name": "ETag", + "value": "W/\"4e-dXPS7nEYaa6r6PVjN9RjHjrHaLU\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AsdmvN2_ZNE0YlwQY5GxY04ptWTOYR5NU.kkH0dnWlEMsblzPMurLX8nsQRRbRqLqteIhA0621onY; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 78, + "mimeType": "application/json; charset=utf-8", + "text": "eyJzdGF0dXMiOiJwYXNzIiwibWVzc2FnZSI6Ik9BdXRoLTEuMGEgc2lnbmF0dXJlIHZlcmlmaWNhdGlvbiB3YXMgc3VjY2Vzc2Z1bCJ9", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 78 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 2, + "connect": 855, + "ssl": 573, + "send": 0, + "wait": 339, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:09.099+08:00", + "time": 1290, + "request": { + "method": "GET", + "url": "https://postman-echo.com/cookies/set?foo1=bar1&foo2=bar2", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AsdmvN2_ZNE0YlwQY5GxY04ptWTOYR5NU.kkH0dnWlEMsblzPMurLX8nsQRRbRqLqteIhA0621onY" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "ff927796-58d3-4f43-8701-8411747c4313" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AsdmvN2_ZNE0YlwQY5GxY04ptWTOYR5NU.kkH0dnWlEMsblzPMurLX8nsQRRbRqLqteIhA0621onY" + } + ], + "queryString": [ + { + "name": "foo1", + "value": "bar1" + }, + { + "name": "foo2", + "value": "bar2" + } + ], + "headersSize": 359, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 302, + "statusText": "Found", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "foo1", + "value": "bar1", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": false, + "secure": false, + "comment": null, + "_maxAge": null + }, + { + "name": "foo2", + "value": "bar2", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": false, + "secure": false, + "comment": null, + "_maxAge": null + }, + { + "name": "sails.sid", + "value": "s%3AlVpTnkb0ofz6HC7QJMVtiRexW3u_onsT.rmsoerMcOQOu7KYPU80x%2FBiieqBESMNj%2FxuCvbbw%2BsQ", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:10 GMT" + }, + { + "name": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "30" + }, + { + "name": "set-cookie", + "value": "foo1=bar1; Path=/" + }, + { + "name": "set-cookie", + "value": "foo2=bar2; Path=/" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AlVpTnkb0ofz6HC7QJMVtiRexW3u_onsT.rmsoerMcOQOu7KYPU80x%2FBiieqBESMNj%2FxuCvbbw%2BsQ; Path=/; HttpOnly" + }, + { + "name": "Location", + "value": "/cookies" + }, + { + "name": "Vary", + "value": "Accept, Accept-Encoding" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 30, + "mimeType": "text/plain; charset=utf-8", + "text": "Found. Redirecting to /cookies" + }, + "redirectURL": "/cookies", + "headersSize": 0, + "bodySize": 30 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 988, + "ssl": 626, + "send": 0, + "wait": 301, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:10.405+08:00", + "time": 1191, + "request": { + "method": "GET", + "url": "https://postman-echo.com/cookies", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AlVpTnkb0ofz6HC7QJMVtiRexW3u_onsT.rmsoerMcOQOu7KYPU80x%2FBiieqBESMNj%2FxuCvbbw%2BsQ" + }, + { + "name": "foo1", + "value": "bar1" + }, + { + "name": "foo2", + "value": "bar2" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "ff927796-58d3-4f43-8701-8411747c4313" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AlVpTnkb0ofz6HC7QJMVtiRexW3u_onsT.rmsoerMcOQOu7KYPU80x%2FBiieqBESMNj%2FxuCvbbw%2BsQ; foo1=bar1; foo2=bar2" + }, + { + "name": "Referer", + "value": "https://postman-echo.com/cookies/set?foo1=bar1&foo2=bar2" + }, + { + "name": "Host", + "value": "postman-echo.com" + } + ], + "queryString": [], + "headersSize": 430, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3Avz13GzkqWaYvFuB3I35udi2vLsikZZgi.YgVWfqmyjPpEduyCIZDFGyDSPYY8%2FFM7HePC5Ok0hQM", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:11 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "41" + }, + { + "name": "ETag", + "value": "W/\"29-JRHqGq7F5tGozH71XMqVk/pLueo\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3Avz13GzkqWaYvFuB3I35udi2vLsikZZgi.YgVWfqmyjPpEduyCIZDFGyDSPYY8%2FFM7HePC5Ok0hQM; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 41, + "mimeType": "application/json; charset=utf-8", + "text": "eyJjb29raWVzIjp7ImZvbzEiOiJiYXIxIiwiZm9vMiI6ImJhcjIifX0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 41 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 902, + "ssl": 620, + "send": 0, + "wait": 287, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:11.630+08:00", + "time": 1172, + "request": { + "method": "GET", + "url": "https://postman-echo.com/cookies", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3Avz13GzkqWaYvFuB3I35udi2vLsikZZgi.YgVWfqmyjPpEduyCIZDFGyDSPYY8%2FFM7HePC5Ok0hQM" + }, + { + "name": "foo1", + "value": "bar1" + }, + { + "name": "foo2", + "value": "bar2" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "2dbc6d22-1713-4b96-a1a2-3358b1a1deaa" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3Avz13GzkqWaYvFuB3I35udi2vLsikZZgi.YgVWfqmyjPpEduyCIZDFGyDSPYY8%2FFM7HePC5Ok0hQM; foo1=bar1; foo2=bar2" + } + ], + "queryString": [], + "headersSize": 359, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AQ8MT5sT-2LAO0Rk7bNLLR18UQWgaJMsg.eOEyhDjqWGwn2rdqWeGLstPmrn5H1OUZGlDLuI%2F1Nng", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:12 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "41" + }, + { + "name": "ETag", + "value": "W/\"29-JRHqGq7F5tGozH71XMqVk/pLueo\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AQ8MT5sT-2LAO0Rk7bNLLR18UQWgaJMsg.eOEyhDjqWGwn2rdqWeGLstPmrn5H1OUZGlDLuI%2F1Nng; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 41, + "mimeType": "application/json; charset=utf-8", + "text": "eyJjb29raWVzIjp7ImZvbzEiOiJiYXIxIiwiZm9vMiI6ImJhcjIifX0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 41 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 887, + "ssl": 607, + "send": 0, + "wait": 283, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:12.841+08:00", + "time": 1436, + "request": { + "method": "GET", + "url": "https://postman-echo.com/cookies/delete?foo1&foo2", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AQ8MT5sT-2LAO0Rk7bNLLR18UQWgaJMsg.eOEyhDjqWGwn2rdqWeGLstPmrn5H1OUZGlDLuI%2F1Nng" + }, + { + "name": "foo1", + "value": "bar1" + }, + { + "name": "foo2", + "value": "bar2" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "8837dd89-9db7-4f06-9187-e7a85a99b945" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AQ8MT5sT-2LAO0Rk7bNLLR18UQWgaJMsg.eOEyhDjqWGwn2rdqWeGLstPmrn5H1OUZGlDLuI%2F1Nng; foo1=bar1; foo2=bar2" + } + ], + "queryString": [ + { + "name": "foo1", + "value": "" + }, + { + "name": "foo2", + "value": "" + } + ], + "headersSize": 376, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 302, + "statusText": "Found", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "foo1", + "value": "", + "path": "/", + "domain": null, + "expires": "Thu, 01 Jan 1970 00:00:00 GMT", + "httpOnly": false, + "secure": false, + "comment": null, + "_maxAge": null + }, + { + "name": "foo2", + "value": "", + "path": "/", + "domain": null, + "expires": "Thu, 01 Jan 1970 00:00:00 GMT", + "httpOnly": false, + "secure": false, + "comment": null, + "_maxAge": null + }, + { + "name": "sails.sid", + "value": "s%3A1atMUPWbEEDiMqdbTqbddbqiFujSi1l2.6n40eqlOkTsKoB6K7xT98PrfQweiPlTjJTfZl%2FpAEsU", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:14 GMT" + }, + { + "name": "Content-Type", + "value": "text/plain; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "30" + }, + { + "name": "set-cookie", + "value": "foo1=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT" + }, + { + "name": "set-cookie", + "value": "foo2=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3A1atMUPWbEEDiMqdbTqbddbqiFujSi1l2.6n40eqlOkTsKoB6K7xT98PrfQweiPlTjJTfZl%2FpAEsU; Path=/; HttpOnly" + }, + { + "name": "Location", + "value": "/cookies" + }, + { + "name": "Vary", + "value": "Accept, Accept-Encoding" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 30, + "mimeType": "text/plain; charset=utf-8", + "text": "Found. Redirecting to /cookies" + }, + "redirectURL": "/cookies", + "headersSize": 0, + "bodySize": 30 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 1018, + "ssl": 694, + "send": 0, + "wait": 417, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:14.291+08:00", + "time": 1394, + "request": { + "method": "GET", + "url": "https://postman-echo.com/cookies", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A1atMUPWbEEDiMqdbTqbddbqiFujSi1l2.6n40eqlOkTsKoB6K7xT98PrfQweiPlTjJTfZl%2FpAEsU" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "8837dd89-9db7-4f06-9187-e7a85a99b945" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3A1atMUPWbEEDiMqdbTqbddbqiFujSi1l2.6n40eqlOkTsKoB6K7xT98PrfQweiPlTjJTfZl%2FpAEsU" + }, + { + "name": "Referer", + "value": "https://postman-echo.com/cookies/delete?foo1&foo2" + }, + { + "name": "Host", + "value": "postman-echo.com" + } + ], + "queryString": [], + "headersSize": 397, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A5p9FN9UVGZ9XJl6I9FXiz0AwIQRRU1ka.RFuMLR9arGQaLkM1gbvuPosvzPxsREHGEjjiVF4TXnQ", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:15 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "14" + }, + { + "name": "ETag", + "value": "W/\"e-HwHgMXOuquwNiBd0Mx9LHc/Rmfk\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3A5p9FN9UVGZ9XJl6I9FXiz0AwIQRRU1ka.RFuMLR9arGQaLkM1gbvuPosvzPxsREHGEjjiVF4TXnQ; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 14, + "mimeType": "application/json; charset=utf-8", + "text": "eyJjb29raWVzIjp7fX0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 14 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 1109, + "ssl": 715, + "send": 0, + "wait": 283, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:15.722+08:00", + "time": 1176, + "request": { + "method": "GET", + "url": "https://postman-echo.com/status/200", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A5p9FN9UVGZ9XJl6I9FXiz0AwIQRRU1ka.RFuMLR9arGQaLkM1gbvuPosvzPxsREHGEjjiVF4TXnQ" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "5f4c6d97-d476-407e-bbf9-532480f618d8" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3A5p9FN9UVGZ9XJl6I9FXiz0AwIQRRU1ka.RFuMLR9arGQaLkM1gbvuPosvzPxsREHGEjjiVF4TXnQ" + } + ], + "queryString": [], + "headersSize": 338, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AFD7Hy01JAAenWz9SoQQhJxH4Qxel9sbP.%2Ba5JmTwqOpkc%2FAOLOzzsfStpK2MTfZCYXiCoA39Zt7w", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:17 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "14" + }, + { + "name": "ETag", + "value": "W/\"e-QlsUp1vTYvBgYHrHCBYe2n/q268\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AFD7Hy01JAAenWz9SoQQhJxH4Qxel9sbP.%2Ba5JmTwqOpkc%2FAOLOzzsfStpK2MTfZCYXiCoA39Zt7w; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 14, + "mimeType": "application/json; charset=utf-8", + "text": "eyJzdGF0dXMiOjIwMH0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 14 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 2, + "connect": 887, + "ssl": 607, + "send": 0, + "wait": 287, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:16.933+08:00", + "time": 1223, + "request": { + "method": "GET", + "url": "https://postman-echo.com/stream/5", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AFD7Hy01JAAenWz9SoQQhJxH4Qxel9sbP.%2Ba5JmTwqOpkc%2FAOLOzzsfStpK2MTfZCYXiCoA39Zt7w" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "24ca01aa-6c3f-4a78-a437-33dfa8dadd0f" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AFD7Hy01JAAenWz9SoQQhJxH4Qxel9sbP.%2Ba5JmTwqOpkc%2FAOLOzzsfStpK2MTfZCYXiCoA39Zt7w" + } + ], + "queryString": [], + "headersSize": 340, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AqSePO9_VmCbBbVvsCMYMHm3lShKdFNWU.RFuwKJdlZHVyB0gF1x2Yt78v5jKbese6f8HNPIjI5AY", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:18 GMT" + }, + { + "name": "Transfer-Encoding", + "value": "chunked" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AqSePO9_VmCbBbVvsCMYMHm3lShKdFNWU.RFuwKJdlZHVyB0gF1x2Yt78v5jKbese6f8HNPIjI5AY; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 2885, + "mimeType": null, + "text": "ewogICJhcmdzIjogewogICAgIm4iOiAiNSIKICB9LAogICJoZWFkZXJzIjogewogICAgIngtZm9yd2FyZGVkLXByb3RvIjogImh0dHBzIiwKICAgICJ4LWZvcndhcmRlZC1wb3J0IjogIjQ0MyIsCiAgICAiaG9zdCI6ICJwb3N0bWFuLWVjaG8uY29tIiwKICAgICJ4LWFtem4tdHJhY2UtaWQiOiAiUm9vdD0xLTYxNmE3OWFlLTZiNjY3YzdjMTBjNDI4Y2QzNWQ2ZTJhZCIsCiAgICAidXNlci1hZ2VudCI6ICJQb3N0bWFuUnVudGltZS83LjI4LjQiLAogICAgImFjY2VwdCI6ICIqLyoiLAogICAgImNhY2hlLWNvbnRyb2wiOiAibm8tY2FjaGUiLAogICAgInBvc3RtYW4tdG9rZW4iOiAiMjRjYTAxYWEtNmMzZi00YTc4LWE0MzctMzNkZmE4ZGFkZDBmIiwKICAgICJhY2NlcHQtZW5jb2RpbmciOiAiZ3ppcCwgZGVmbGF0ZSwgYnIiLAogICAgImNvb2tpZSI6ICJzYWlscy5zaWQ9cyUzQUZEN0h5MDFKQUFlbld6OVNvUVFoSnhINFF4ZWw5c2JQLiUyQmE1Sm1Ud3FPcGtjJTJGQU9MT3p6c2ZTdHBLMk1UZlpDWVhpQ29BMzladDd3IgogIH0sCiAgInVybCI6ICJodHRwczovL3Bvc3RtYW4tZWNoby5jb20vc3RyZWFtLzUiCn17CiAgImFyZ3MiOiB7CiAgICAibiI6ICI1IgogIH0sCiAgImhlYWRlcnMiOiB7CiAgICAieC1mb3J3YXJkZWQtcHJvdG8iOiAiaHR0cHMiLAogICAgIngtZm9yd2FyZGVkLXBvcnQiOiAiNDQzIiwKICAgICJob3N0IjogInBvc3RtYW4tZWNoby5jb20iLAogICAgIngtYW16bi10cmFjZS1pZCI6ICJSb290PTEtNjE2YTc5YWUtNmI2NjdjN2MxMGM0MjhjZDM1ZDZlMmFkIiwKICAgICJ1c2VyLWFnZW50IjogIlBvc3RtYW5SdW50aW1lLzcuMjguNCIsCiAgICAiYWNjZXB0IjogIiovKiIsCiAgICAiY2FjaGUtY29udHJvbCI6ICJuby1jYWNoZSIsCiAgICAicG9zdG1hbi10b2tlbiI6ICIyNGNhMDFhYS02YzNmLTRhNzgtYTQzNy0zM2RmYThkYWRkMGYiLAogICAgImFjY2VwdC1lbmNvZGluZyI6ICJnemlwLCBkZWZsYXRlLCBiciIsCiAgICAiY29va2llIjogInNhaWxzLnNpZD1zJTNBRkQ3SHkwMUpBQWVuV3o5U29RUWhKeEg0UXhlbDlzYlAuJTJCYTVKbVR3cU9wa2MlMkZBT0xPenpzZlN0cEsyTVRmWkNZWGlDb0EzOVp0N3ciCiAgfSwKICAidXJsIjogImh0dHBzOi8vcG9zdG1hbi1lY2hvLmNvbS9zdHJlYW0vNSIKfXsKICAiYXJncyI6IHsKICAgICJuIjogIjUiCiAgfSwKICAiaGVhZGVycyI6IHsKICAgICJ4LWZvcndhcmRlZC1wcm90byI6ICJodHRwcyIsCiAgICAieC1mb3J3YXJkZWQtcG9ydCI6ICI0NDMiLAogICAgImhvc3QiOiAicG9zdG1hbi1lY2hvLmNvbSIsCiAgICAieC1hbXpuLXRyYWNlLWlkIjogIlJvb3Q9MS02MTZhNzlhZS02YjY2N2M3YzEwYzQyOGNkMzVkNmUyYWQiLAogICAgInVzZXItYWdlbnQiOiAiUG9zdG1hblJ1bnRpbWUvNy4yOC40IiwKICAgICJhY2NlcHQiOiAiKi8qIiwKICAgICJjYWNoZS1jb250cm9sIjogIm5vLWNhY2hlIiwKICAgICJwb3N0bWFuLXRva2VuIjogIjI0Y2EwMWFhLTZjM2YtNGE3OC1hNDM3LTMzZGZhOGRhZGQwZiIsCiAgICAiYWNjZXB0LWVuY29kaW5nIjogImd6aXAsIGRlZmxhdGUsIGJyIiwKICAgICJjb29raWUiOiAic2FpbHMuc2lkPXMlM0FGRDdIeTAxSkFBZW5XejlTb1FRaEp4SDRReGVsOXNiUC4lMkJhNUptVHdxT3BrYyUyRkFPTE96enNmU3RwSzJNVGZaQ1lYaUNvQTM5WnQ3dyIKICB9LAogICJ1cmwiOiAiaHR0cHM6Ly9wb3N0bWFuLWVjaG8uY29tL3N0cmVhbS81Igp9ewogICJhcmdzIjogewogICAgIm4iOiAiNSIKICB9LAogICJoZWFkZXJzIjogewogICAgIngtZm9yd2FyZGVkLXByb3RvIjogImh0dHBzIiwKICAgICJ4LWZvcndhcmRlZC1wb3J0IjogIjQ0MyIsCiAgICAiaG9zdCI6ICJwb3N0bWFuLWVjaG8uY29tIiwKICAgICJ4LWFtem4tdHJhY2UtaWQiOiAiUm9vdD0xLTYxNmE3OWFlLTZiNjY3YzdjMTBjNDI4Y2QzNWQ2ZTJhZCIsCiAgICAidXNlci1hZ2VudCI6ICJQb3N0bWFuUnVudGltZS83LjI4LjQiLAogICAgImFjY2VwdCI6ICIqLyoiLAogICAgImNhY2hlLWNvbnRyb2wiOiAibm8tY2FjaGUiLAogICAgInBvc3RtYW4tdG9rZW4iOiAiMjRjYTAxYWEtNmMzZi00YTc4LWE0MzctMzNkZmE4ZGFkZDBmIiwKICAgICJhY2NlcHQtZW5jb2RpbmciOiAiZ3ppcCwgZGVmbGF0ZSwgYnIiLAogICAgImNvb2tpZSI6ICJzYWlscy5zaWQ9cyUzQUZEN0h5MDFKQUFlbld6OVNvUVFoSnhINFF4ZWw5c2JQLiUyQmE1Sm1Ud3FPcGtjJTJGQU9MT3p6c2ZTdHBLMk1UZlpDWVhpQ29BMzladDd3IgogIH0sCiAgInVybCI6ICJodHRwczovL3Bvc3RtYW4tZWNoby5jb20vc3RyZWFtLzUiCn17CiAgImFyZ3MiOiB7CiAgICAibiI6ICI1IgogIH0sCiAgImhlYWRlcnMiOiB7CiAgICAieC1mb3J3YXJkZWQtcHJvdG8iOiAiaHR0cHMiLAogICAgIngtZm9yd2FyZGVkLXBvcnQiOiAiNDQzIiwKICAgICJob3N0IjogInBvc3RtYW4tZWNoby5jb20iLAogICAgIngtYW16bi10cmFjZS1pZCI6ICJSb290PTEtNjE2YTc5YWUtNmI2NjdjN2MxMGM0MjhjZDM1ZDZlMmFkIiwKICAgICJ1c2VyLWFnZW50IjogIlBvc3RtYW5SdW50aW1lLzcuMjguNCIsCiAgICAiYWNjZXB0IjogIiovKiIsCiAgICAiY2FjaGUtY29udHJvbCI6ICJuby1jYWNoZSIsCiAgICAicG9zdG1hbi10b2tlbiI6ICIyNGNhMDFhYS02YzNmLTRhNzgtYTQzNy0zM2RmYThkYWRkMGYiLAogICAgImFjY2VwdC1lbmNvZGluZyI6ICJnemlwLCBkZWZsYXRlLCBiciIsCiAgICAiY29va2llIjogInNhaWxzLnNpZD1zJTNBRkQ3SHkwMUpBQWVuV3o5U29RUWhKeEg0UXhlbDlzYlAuJTJCYTVKbVR3cU9wa2MlMkZBT0xPenpzZlN0cEsyTVRmWkNZWGlDb0EzOVp0N3ciCiAgfSwKICAidXJsIjogImh0dHBzOi8vcG9zdG1hbi1lY2hvLmNvbS9zdHJlYW0vNSIKfQ==", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 2885 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 2, + "connect": 848, + "ssl": 570, + "send": 1, + "wait": 371, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:18.194+08:00", + "time": 3145, + "request": { + "method": "GET", + "url": "https://postman-echo.com/delay/2", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AqSePO9_VmCbBbVvsCMYMHm3lShKdFNWU.RFuwKJdlZHVyB0gF1x2Yt78v5jKbese6f8HNPIjI5AY" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "d2ade32f-4bb8-4e6d-90d3-5fa7560def12" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AqSePO9_VmCbBbVvsCMYMHm3lShKdFNWU.RFuwKJdlZHVyB0gF1x2Yt78v5jKbese6f8HNPIjI5AY" + } + ], + "queryString": [], + "headersSize": 335, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AXrCX-GaGzqizPQY2AdLTLNPO_cFgVsGD.BwOoj2gClsAzDrsP0%2FObypcumuYCfV%2F4vHCrKIWdTAQ", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:21 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "13" + }, + { + "name": "ETag", + "value": "W/\"d-vb8pS8uHJYunqF73qADGxcv0Je8\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AXrCX-GaGzqizPQY2AdLTLNPO_cFgVsGD.BwOoj2gClsAzDrsP0%2FObypcumuYCfV%2F4vHCrKIWdTAQ; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 13, + "mimeType": "application/json; charset=utf-8", + "text": "eyJkZWxheSI6IjIifQ==", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 13 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 857, + "ssl": 572, + "send": 1, + "wait": 2285, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:21.376+08:00", + "time": 1182, + "request": { + "method": "GET", + "url": "https://postman-echo.com/encoding/utf8", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AXrCX-GaGzqizPQY2AdLTLNPO_cFgVsGD.BwOoj2gClsAzDrsP0%2FObypcumuYCfV%2F4vHCrKIWdTAQ" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "bd39f8e4-8072-4ec3-b498-3aaacb621544" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AXrCX-GaGzqizPQY2AdLTLNPO_cFgVsGD.BwOoj2gClsAzDrsP0%2FObypcumuYCfV%2F4vHCrKIWdTAQ" + } + ], + "queryString": [], + "headersSize": 345, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:22 GMT" + }, + { + "name": "Content-Type", + "value": "text/html; charset=utf-8" + }, + { + "name": "Transfer-Encoding", + "value": "chunked" + }, + { + "name": "ETag", + "value": "W/\"3d0e-bb1Z6nxw+98ped7xrePAFKVeCtU\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "Content-Encoding", + "value": "gzip" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 15630, + "compression": 9411, + "mimeType": "text/html; charset=utf-8", + "text": "\n \n \n

Unicode Demo

\n\n

Taken from \n http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-demo.txt

\n\n
\n\n        UTF-8 encoded sample plain-text file\n        ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\n\n        Markus Kuhn [ˈmaʳkʊs kuːn]  — 2002-07-25\n\n\n        The ASCII compatible UTF-8 encoding used in this plain-text file\n        is defined in Unicode, ISO 10646-1, and RFC 2279.\n\n\n        Using Unicode/UTF-8, you can write in emails and source code things such as\n\n        Mathematics and sciences:\n\n          ∮ E⋅da = Q,  n → ∞, ∑ f(i) = ∏ g(i),      ⎧⎡⎛┌─────┐⎞⎤⎫\n                                                    ⎪⎢⎜│a²+b³ ⎟⎥⎪\n          ∀x∈ℝ: ⌈x⌉ = −⌊−x⌋, α ∧ ¬β = ¬(¬α ∨ β),    ⎪⎢⎜│───── ⎟⎥⎪\n                                                    ⎪⎢⎜⎷ c₈   ⎟⎥⎪\n          ℕ ⊆ ℕ₀ ⊂ ℤ ⊂ ℚ ⊂ ℝ ⊂ ℂ,                   ⎨⎢⎜       ⎟⎥⎬\n                                                    ⎪⎢⎜ ∞     ⎟⎥⎪\n          ⊥ < a ≠ b ≡ c ≤ d ≪ ⊤ ⇒ (⟦A⟧ ⇔ ⟪B⟫),      ⎪⎢⎜ ⎲     ⎟⎥⎪\n                                                    ⎪⎢⎜ ⎳aⁱ-bⁱ⎟⎥⎪\n          2H₂ + O₂ ⇌ 2H₂O, R = 4.7 kΩ, ⌀ 200 mm     ⎩⎣⎝i=1    ⎠⎦⎭\n\n        Linguistics and dictionaries:\n\n          ði ıntəˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn\n          Y [ˈʏpsilɔn], Yen [jɛn], Yoga [ˈjoːgɑ]\n\n        APL:\n\n          ((V⍳V)=⍳⍴V)/V←,V    ⌷←⍳→⍴∆∇⊃‾⍎⍕⌈\n\n        Nicer typography in plain text files:\n\n          ╔══════════════════════════════════════════╗\n          ║                                          ║\n          ║   • ‘single’ and “double” quotes         ║\n          ║                                          ║\n          ║   • Curly apostrophes: “We’ve been here” ║\n          ║                                          ║\n          ║   • Latin-1 apostrophe and accents: '´`  ║\n          ║                                          ║\n          ║   • ‚deutsche‘ „Anführungszeichen“       ║\n          ║                                          ║\n          ║   • †, ‡, ‰, •, 3–4, —, −5/+5, ™, …      ║\n          ║                                          ║\n          ║   • ASCII safety test: 1lI|, 0OD, 8B     ║\n          ║                      ╭─────────╮         ║\n          ║   • the euro symbol: │ 14.95 € │         ║\n          ║                      ╰─────────╯         ║\n          ╚══════════════════════════════════════════╝\n\n        Combining characters:\n\n          STARGΛ̊TE SG-1, a = v̇ = r̈, a⃑ ⊥ b⃑\n\n        Greek (in Polytonic):\n\n          The Greek anthem:\n\n          Σὲ γνωρίζω ἀπὸ τὴν κόψη\n          τοῦ σπαθιοῦ τὴν τρομερή,\n          σὲ γνωρίζω ἀπὸ τὴν ὄψη\n          ποὺ μὲ βία μετράει τὴ γῆ.\n\n          ᾿Απ᾿ τὰ κόκκαλα βγαλμένη\n          τῶν ῾Ελλήνων τὰ ἱερά\n          καὶ σὰν πρῶτα ἀνδρειωμένη\n          χαῖρε, ὦ χαῖρε, ᾿Ελευθεριά!\n\n          From a speech of Demosthenes in the 4th century BC:\n\n          Οὐχὶ ταὐτὰ παρίσταταί μοι γιγνώσκειν, ὦ ἄνδρες ᾿Αθηναῖοι,\n          ὅταν τ᾿ εἰς τὰ πράγματα ἀποβλέψω καὶ ὅταν πρὸς τοὺς\n          λόγους οὓς ἀκούω· τοὺς μὲν γὰρ λόγους περὶ τοῦ\n          τιμωρήσασθαι Φίλιππον ὁρῶ γιγνομένους, τὰ δὲ πράγματ᾿\n          εἰς τοῦτο προήκοντα,  ὥσθ᾿ ὅπως μὴ πεισόμεθ᾿ αὐτοὶ\n          πρότερον κακῶς σκέψασθαι δέον. οὐδέν οὖν ἄλλο μοι δοκοῦσιν\n          οἱ τὰ τοιαῦτα λέγοντες ἢ τὴν ὑπόθεσιν, περὶ ἧς βουλεύεσθαι,\n          οὐχὶ τὴν οὖσαν παριστάντες ὑμῖν ἁμαρτάνειν. ἐγὼ δέ, ὅτι μέν\n          ποτ᾿ ἐξῆν τῇ πόλει καὶ τὰ αὑτῆς ἔχειν ἀσφαλῶς καὶ Φίλιππον\n          τιμωρήσασθαι, καὶ μάλ᾿ ἀκριβῶς οἶδα· ἐπ᾿ ἐμοῦ γάρ, οὐ πάλαι\n          γέγονεν ταῦτ᾿ ἀμφότερα· νῦν μέντοι πέπεισμαι τοῦθ᾿ ἱκανὸν\n          προλαβεῖν ἡμῖν εἶναι τὴν πρώτην, ὅπως τοὺς συμμάχους\n          σώσομεν. ἐὰν γὰρ τοῦτο βεβαίως ὑπάρξῃ, τότε καὶ περὶ τοῦ\n          τίνα τιμωρήσεταί τις καὶ ὃν τρόπον ἐξέσται σκοπεῖν· πρὶν δὲ\n          τὴν ἀρχὴν ὀρθῶς ὑποθέσθαι, μάταιον ἡγοῦμαι περὶ τῆς\n          τελευτῆς ὁντινοῦν ποιεῖσθαι λόγον.\n\n          Δημοσθένους, Γ´ ᾿Ολυνθιακὸς\n\n        Georgian:\n\n          From a Unicode conference invitation:\n\n          გთხოვთ ახლავე გაიაროთ რეგისტრაცია Unicode-ის მეათე საერთაშორისო\n          კონფერენციაზე დასასწრებად, რომელიც გაიმართება 10-12 მარტს,\n          ქ. მაინცში, გერმანიაში. კონფერენცია შეჰკრებს ერთად მსოფლიოს\n          ექსპერტებს ისეთ დარგებში როგორიცაა ინტერნეტი და Unicode-ი,\n          ინტერნაციონალიზაცია და ლოკალიზაცია, Unicode-ის გამოყენება\n          ოპერაციულ სისტემებსა, და გამოყენებით პროგრამებში, შრიფტებში,\n          ტექსტების დამუშავებასა და მრავალენოვან კომპიუტერულ სისტემებში.\n\n        Russian:\n\n          From a Unicode conference invitation:\n\n          Зарегистрируйтесь сейчас на Десятую Международную Конференцию по\n          Unicode, которая состоится 10-12 марта 1997 года в Майнце в Германии.\n          Конференция соберет широкий круг экспертов по  вопросам глобального\n          Интернета и Unicode, локализации и интернационализации, воплощению и\n          применению Unicode в различных операционных системах и программных\n          приложениях, шрифтах, верстке и многоязычных компьютерных системах.\n\n        Thai (UCS Level 2):\n\n          Excerpt from a poetry on The Romance of The Three Kingdoms (a Chinese\n          classic 'San Gua'):\n\n          [----------------------------|------------------------]\n            ๏ แผ่นดินฮั่นเสื่อมโทรมแสนสังเวช  พระปกเกศกองบู๊กู้ขึ้นใหม่\n          สิบสองกษัตริย์ก่อนหน้าแลถัดไป       สององค์ไซร้โง่เขลาเบาปัญญา\n            ทรงนับถือขันทีเป็นที่พึ่ง           บ้านเมืองจึงวิปริตเป็นนักหนา\n          โฮจิ๋นเรียกทัพทั่วหัวเมืองมา         หมายจะฆ่ามดชั่วตัวสำคัญ\n            เหมือนขับไสไล่เสือจากเคหา      รับหมาป่าเข้ามาเลยอาสัญ\n          ฝ่ายอ้องอุ้นยุแยกให้แตกกัน          ใช้สาวนั้นเป็นชนวนชื่นชวนใจ\n            พลันลิฉุยกุยกีกลับก่อเหตุ          ช่างอาเพศจริงหนาฟ้าร้องไห้\n          ต้องรบราฆ่าฟันจนบรรลัย           ฤๅหาใครค้ำชูกู้บรรลังก์ ฯ\n\n          (The above is a two-column text. If combining characters are handled\n          correctly, the lines of the second column should be aligned with the\n          | character above.)\n\n        Ethiopian:\n\n          Proverbs in the Amharic language:\n\n          ሰማይ አይታረስ ንጉሥ አይከሰስ።\n          ብላ ካለኝ እንደአባቴ በቆመጠኝ።\n          ጌጥ ያለቤቱ ቁምጥና ነው።\n          ደሀ በሕልሙ ቅቤ ባይጠጣ ንጣት በገደለው።\n          የአፍ ወለምታ በቅቤ አይታሽም።\n          አይጥ በበላ ዳዋ ተመታ።\n          ሲተረጉሙ ይደረግሙ።\n          ቀስ በቀስ፥ ዕንቁላል በእግሩ ይሄዳል።\n          ድር ቢያብር አንበሳ ያስር።\n          ሰው እንደቤቱ እንጅ እንደ ጉረቤቱ አይተዳደርም።\n          እግዜር የከፈተውን ጉሮሮ ሳይዘጋው አይድርም።\n          የጎረቤት ሌባ፥ ቢያዩት ይስቅ ባያዩት ያጠልቅ።\n          ሥራ ከመፍታት ልጄን ላፋታት።\n          ዓባይ ማደሪያ የለው፥ ግንድ ይዞ ይዞራል።\n          የእስላም አገሩ መካ የአሞራ አገሩ ዋርካ።\n          ተንጋሎ ቢተፉ ተመልሶ ባፉ።\n          ወዳጅህ ማር ቢሆን ጨርስህ አትላሰው።\n          እግርህን በፍራሽህ ልክ ዘርጋ።\n\n        Runes:\n\n          ᚻᛖ ᚳᚹᚫᚦ ᚦᚫᛏ ᚻᛖ ᛒᚢᛞᛖ ᚩᚾ ᚦᚫᛗ ᛚᚪᚾᛞᛖ ᚾᚩᚱᚦᚹᛖᚪᚱᛞᚢᛗ ᚹᛁᚦ ᚦᚪ ᚹᛖᛥᚫ\n\n          (Old English, which transcribed into Latin reads 'He cwaeth that he\n          bude thaem lande northweardum with tha Westsae.' and means 'He said\n          that he lived in the northern land near the Western Sea.')\n\n        Braille:\n\n          ⡌⠁⠧⠑ ⠼⠁⠒  ⡍⠜⠇⠑⠹⠰⠎ ⡣⠕⠌\n\n          ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠙⠑⠁⠙⠒ ⠞⠕ ⠃⠑⠛⠔ ⠺⠊⠹⠲ ⡹⠻⠑ ⠊⠎ ⠝⠕ ⠙⠳⠃⠞\n          ⠱⠁⠞⠑⠧⠻ ⠁⠃⠳⠞ ⠹⠁⠞⠲ ⡹⠑ ⠗⠑⠛⠊⠌⠻ ⠕⠋ ⠙⠊⠎ ⠃⠥⠗⠊⠁⠇ ⠺⠁⠎\n          ⠎⠊⠛⠝⠫ ⠃⠹ ⠹⠑ ⠊⠇⠻⠛⠹⠍⠁⠝⠂ ⠹⠑ ⠊⠇⠻⠅⠂ ⠹⠑ ⠥⠝⠙⠻⠞⠁⠅⠻⠂\n          ⠁⠝⠙ ⠹⠑ ⠡⠊⠑⠋ ⠍⠳⠗⠝⠻⠲ ⡎⠊⠗⠕⠕⠛⠑ ⠎⠊⠛⠝⠫ ⠊⠞⠲ ⡁⠝⠙\n          ⡎⠊⠗⠕⠕⠛⠑⠰⠎ ⠝⠁⠍⠑ ⠺⠁⠎ ⠛⠕⠕⠙ ⠥⠏⠕⠝ ⠰⡡⠁⠝⠛⠑⠂ ⠋⠕⠗ ⠁⠝⠹⠹⠔⠛ ⠙⠑\n          ⠡⠕⠎⠑ ⠞⠕ ⠏⠥⠞ ⠙⠊⠎ ⠙⠁⠝⠙ ⠞⠕⠲\n\n          ⡕⠇⠙ ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲\n\n          ⡍⠔⠙⠖ ⡊ ⠙⠕⠝⠰⠞ ⠍⠑⠁⠝ ⠞⠕ ⠎⠁⠹ ⠹⠁⠞ ⡊ ⠅⠝⠪⠂ ⠕⠋ ⠍⠹\n          ⠪⠝ ⠅⠝⠪⠇⠫⠛⠑⠂ ⠱⠁⠞ ⠹⠻⠑ ⠊⠎ ⠏⠜⠞⠊⠊⠥⠇⠜⠇⠹ ⠙⠑⠁⠙ ⠁⠃⠳⠞\n          ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲ ⡊ ⠍⠊⠣⠞ ⠙⠁⠧⠑ ⠃⠑⠲ ⠔⠊⠇⠔⠫⠂ ⠍⠹⠎⠑⠇⠋⠂ ⠞⠕\n          ⠗⠑⠛⠜⠙ ⠁ ⠊⠕⠋⠋⠔⠤⠝⠁⠊⠇ ⠁⠎ ⠹⠑ ⠙⠑⠁⠙⠑⠌ ⠏⠊⠑⠊⠑ ⠕⠋ ⠊⠗⠕⠝⠍⠕⠝⠛⠻⠹\n          ⠔ ⠹⠑ ⠞⠗⠁⠙⠑⠲ ⡃⠥⠞ ⠹⠑ ⠺⠊⠎⠙⠕⠍ ⠕⠋ ⠳⠗ ⠁⠝⠊⠑⠌⠕⠗⠎\n          ⠊⠎ ⠔ ⠹⠑ ⠎⠊⠍⠊⠇⠑⠆ ⠁⠝⠙ ⠍⠹ ⠥⠝⠙⠁⠇⠇⠪⠫ ⠙⠁⠝⠙⠎\n          ⠩⠁⠇⠇ ⠝⠕⠞ ⠙⠊⠌⠥⠗⠃ ⠊⠞⠂ ⠕⠗ ⠹⠑ ⡊⠳⠝⠞⠗⠹⠰⠎ ⠙⠕⠝⠑ ⠋⠕⠗⠲ ⡹⠳\n          ⠺⠊⠇⠇ ⠹⠻⠑⠋⠕⠗⠑ ⠏⠻⠍⠊⠞ ⠍⠑ ⠞⠕ ⠗⠑⠏⠑⠁⠞⠂ ⠑⠍⠏⠙⠁⠞⠊⠊⠁⠇⠇⠹⠂ ⠹⠁⠞\n          ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲\n\n          (The first couple of paragraphs of \"A Christmas Carol\" by Dickens)\n\n        Compact font selection example text:\n\n          ABCDEFGHIJKLMNOPQRSTUVWXYZ /0123456789\n          abcdefghijklmnopqrstuvwxyz £©µÀÆÖÞßéöÿ\n          –—‘“”„†•…‰™œŠŸž€ ΑΒΓΔΩαβγδω АБВГДабвгд\n          ∀∂∈ℝ∧∪≡∞ ↑↗↨↻⇣ ┐┼╔╘░►☺♀ fi�⑀₂ἠḂӥẄɐː⍎אԱა\n\n        Greetings in various languages:\n\n          Hello world, Καλημέρα κόσμε, コンニチハ\n\n        Box drawing alignment tests:                                          █\n                                                                              ▉\n          ╔══╦══╗  ┌──┬──┐  ╭──┬──╮  ╭──┬──╮  ┏━━┳━━┓  ┎┒┏┑   ╷  ╻ ┏┯┓ ┌┰┐    ▊ ╱╲╱╲╳╳╳\n          ║┌─╨─┐║  │╔═╧═╗│  │╒═╪═╕│  │╓─╁─╖│  ┃┌─╂─┐┃  ┗╃╄┙  ╶┼╴╺╋╸┠┼┨ ┝╋┥    ▋ ╲╱╲╱╳╳╳\n          ║│╲ ╱│║  │║   ║│  ││ │ ││  │║ ┃ ║│  ┃│ ╿ │┃  ┍╅╆┓   ╵  ╹ ┗┷┛ └┸┘    ▌ ╱╲╱╲╳╳╳\n          ╠╡ ╳ ╞╣  ├╢   ╟┤  ├┼─┼─┼┤  ├╫─╂─╫┤  ┣┿╾┼╼┿┫  ┕┛┖┚     ┌┄┄┐ ╎ ┏┅┅┓ ┋ ▍ ╲╱╲╱╳╳╳\n          ║│╱ ╲│║  │║   ║│  ││ │ ││  │║ ┃ ║│  ┃│ ╽ │┃  ░░▒▒▓▓██ ┊  ┆ ╎ ╏  ┇ ┋ ▎\n          ║└─╥─┘║  │╚═╤═╝│  │╘═╪═╛│  │╙─╀─╜│  ┃└─╂─┘┃  ░░▒▒▓▓██ ┊  ┆ ╎ ╏  ┇ ┋ ▏\n          ╚══╩══╝  └──┴──┘  ╰──┴──╯  ╰──┴──╯  ┗━━┻━━┛  ▗▄▖▛▀▜   └╌╌┘ ╎ ┗╍╍┛ ┋  ▁▂▃▄▅▆▇█\n                                                       ▝▀▘▙▄▟\n\n        
\n \n \n " + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 6219 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 858, + "ssl": 576, + "send": 0, + "wait": 322, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:22.601+08:00", + "time": 1241, + "request": { + "method": "GET", + "url": "https://postman-echo.com/gzip", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "ef40db18-75f9-4d0c-9fe8-94274a0a589e" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU" + } + ], + "queryString": [], + "headersSize": 338, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:23 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "381" + }, + { + "name": "Content-Encoding", + "value": "gzip" + }, + { + "name": "ETag", + "value": "W/\"17d-oe2gyqLr7HgZNpWMdAxjB727Dps\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 539, + "compression": 158, + "mimeType": "application/json; charset=utf-8", + "text": "ewogICJnemlwcGVkIjogdHJ1ZSwKICAiaGVhZGVycyI6IHsKICAgICJ4LWZvcndhcmRlZC1wcm90byI6ICJodHRwcyIsCiAgICAieC1mb3J3YXJkZWQtcG9ydCI6ICI0NDMiLAogICAgImhvc3QiOiAicG9zdG1hbi1lY2hvLmNvbSIsCiAgICAieC1hbXpuLXRyYWNlLWlkIjogIlJvb3Q9MS02MTZhNzliMy0yNjMyOTc4YjJlZWM0MDAwNDE2ZGY4NTAiLAogICAgInVzZXItYWdlbnQiOiAiUG9zdG1hblJ1bnRpbWUvNy4yOC40IiwKICAgICJhY2NlcHQiOiAiKi8qIiwKICAgICJjYWNoZS1jb250cm9sIjogIm5vLWNhY2hlIiwKICAgICJwb3N0bWFuLXRva2VuIjogImVmNDBkYjE4LTc1ZjktNGQwYy05ZmU4LTk0Mjc0YTBhNTg5ZSIsCiAgICAiYWNjZXB0LWVuY29kaW5nIjogImd6aXAsIGRlZmxhdGUsIGJyIiwKICAgICJjb29raWUiOiAic2FpbHMuc2lkPXMlM0Fka25FVGR2WWlDd1JidHhwV1I1OFpobW9obVpKT3FkSS5TQTglMkZSMDcyQ1prbGRPVHVWdjdUWXlLcHpFUVdwa3QlMkYyWVRUVEJGbiUyQnpVIgogIH0sCiAgIm1ldGhvZCI6ICJHRVQiCn0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 381 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 888, + "ssl": 608, + "send": 1, + "wait": 350, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:23.879+08:00", + "time": 1447, + "request": { + "method": "GET", + "url": "https://postman-echo.com/deflate", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "06b47e94-9131-4ab7-8d0e-d0990f1a1144" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU" + } + ], + "queryString": [], + "headersSize": 341, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A_sZ_Nn5QQ0b2Swfp9tMHX9CWKJb9X3is.fa%2FQ9D9WhuFBgpatC2Yo33cPynch4YqbG%2Fw9iB92Jxo", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:25 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "367" + }, + { + "name": "Content-Encoding", + "value": "deflate" + }, + { + "name": "ETag", + "value": "W/\"16f-6gmrv4fnhXu0S9HAifYY68xUiZc\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3A_sZ_Nn5QQ0b2Swfp9tMHX9CWKJb9X3is.fa%2FQ9D9WhuFBgpatC2Yo33cPynch4YqbG%2Fw9iB92Jxo; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 540, + "compression": 173, + "mimeType": "application/json; charset=utf-8", + "text": "ewogICJkZWZsYXRlZCI6IHRydWUsCiAgImhlYWRlcnMiOiB7CiAgICAieC1mb3J3YXJkZWQtcHJvdG8iOiAiaHR0cHMiLAogICAgIngtZm9yd2FyZGVkLXBvcnQiOiAiNDQzIiwKICAgICJob3N0IjogInBvc3RtYW4tZWNoby5jb20iLAogICAgIngtYW16bi10cmFjZS1pZCI6ICJSb290PTEtNjE2YTc5YjUtNDc1NDU5OWIxZmNkNTU1NTUwNDkxMDdlIiwKICAgICJ1c2VyLWFnZW50IjogIlBvc3RtYW5SdW50aW1lLzcuMjguNCIsCiAgICAiYWNjZXB0IjogIiovKiIsCiAgICAiY2FjaGUtY29udHJvbCI6ICJuby1jYWNoZSIsCiAgICAicG9zdG1hbi10b2tlbiI6ICIwNmI0N2U5NC05MTMxLTRhYjctOGQwZS1kMDk5MGYxYTExNDQiLAogICAgImFjY2VwdC1lbmNvZGluZyI6ICJnemlwLCBkZWZsYXRlLCBiciIsCiAgICAiY29va2llIjogInNhaWxzLnNpZD1zJTNBZGtuRVRkdllpQ3dSYnR4cFdSNThaaG1vaG1aSk9xZEkuU0E4JTJGUjA3MkNaa2xkT1R1VnY3VFl5S3B6RVFXcGt0JTJGMllUVFRCRm4lMkJ6VSIKICB9LAogICJtZXRob2QiOiAiR0VUIgp9", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 367 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 1044, + "ssl": 764, + "send": 0, + "wait": 401, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:25.364+08:00", + "time": 1177, + "request": { + "method": "GET", + "url": "https://postman-echo.com/ip", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A_sZ_Nn5QQ0b2Swfp9tMHX9CWKJb9X3is.fa%2FQ9D9WhuFBgpatC2Yo33cPynch4YqbG%2Fw9iB92Jxo" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "246c423e-9285-4fad-b471-434bf4bf3369" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3A_sZ_Nn5QQ0b2Swfp9tMHX9CWKJb9X3is.fa%2FQ9D9WhuFBgpatC2Yo33cPynch4YqbG%2Fw9iB92Jxo" + } + ], + "queryString": [], + "headersSize": 334, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:26 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "22" + }, + { + "name": "ETag", + "value": "W/\"16-ZXKRURzaxajlwvm0ML1HZbz4Rfw\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 22, + "mimeType": "application/json; charset=utf-8", + "text": "eyJpcCI6IjEyMi4xNC4yMjkuNzkifQ==", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 22 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 889, + "ssl": 606, + "send": 0, + "wait": 286, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:26.576+08:00", + "time": 1194, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/now", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "e1107fa9-80cb-4e69-b3dd-6fd0c92832b1" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs" + } + ], + "queryString": [], + "headersSize": 336, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:27 GMT" + }, + { + "name": "Content-Type", + "value": "text/html; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "29" + }, + { + "name": "ETag", + "value": "W/\"1d-Tr20f4VzzgEG6gD2rRpoAaVOy+A\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 29, + "mimeType": "text/html; charset=utf-8", + "text": "Sat, 16 Oct 2021 07:05:27 GMT" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 29 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 909, + "ssl": 628, + "send": 0, + "wait": 283, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:27.800+08:00", + "time": 1315, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/valid?timestamp=2016-10-10", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "05eb8403-8a83-4bde-bdd4-67952910c00f" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + } + ], + "headersSize": 359, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3Ai_9yOOqBlD9Nq0-5kptXL_qLhgITKpaZ.HU5sTJC0jVIzJvykONaDFYTiMZrZpQgdiwMInhSADss", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:29 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "14" + }, + { + "name": "ETag", + "value": "W/\"e-3MDSGou3nIOvlBZElUyTiBbaRZY\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3Ai_9yOOqBlD9Nq0-5kptXL_qLhgITKpaZ.HU5sTJC0jVIzJvykONaDFYTiMZrZpQgdiwMInhSADss; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 14, + "mimeType": "application/json; charset=utf-8", + "text": "eyJ2YWxpZCI6dHJ1ZX0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 14 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 912, + "ssl": 612, + "send": 0, + "wait": 402, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:29.150+08:00", + "time": 1405, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/format?timestamp=2016-10-10&format=mm", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3Ai_9yOOqBlD9Nq0-5kptXL_qLhgITKpaZ.HU5sTJC0jVIzJvykONaDFYTiMZrZpQgdiwMInhSADss" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "7bab6bdc-6fe5-4eb8-aff0-3cfa08e5a823" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3Ai_9yOOqBlD9Nq0-5kptXL_qLhgITKpaZ.HU5sTJC0jVIzJvykONaDFYTiMZrZpQgdiwMInhSADss" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + }, + { + "name": "format", + "value": "mm" + } + ], + "headersSize": 370, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:30 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "15" + }, + { + "name": "ETag", + "value": "W/\"f-oSXEKZdRgFcBy3nxz+EFgc2p5wo\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 15, + "mimeType": "application/json; charset=utf-8", + "text": "eyJmb3JtYXQiOiIyMCJ9", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 15 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 996, + "ssl": 715, + "send": 0, + "wait": 407, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:30.592+08:00", + "time": 1243, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/unit?timestamp=2016-10-10&unit=day", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "8dbb7595-3ff0-47cd-8883-4c1f24a840ef" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + }, + { + "name": "unit", + "value": "day" + } + ], + "headersSize": 371, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:32 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "10" + }, + { + "name": "ETag", + "value": "W/\"a-Tq86/bt7ViOhfxXgqKCTL6sompk\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 10, + "mimeType": "application/json; charset=utf-8", + "text": "eyJ1bml0IjoxfQ==", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 10 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 3, + "connect": 958, + "ssl": 586, + "send": 0, + "wait": 282, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:31.870+08:00", + "time": 1223, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/add?timestamp=2016-10-10&years=100", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "12c5137f-ee8e-48c2-b1b7-99c85f0667e4" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + }, + { + "name": "years", + "value": "100" + } + ], + "headersSize": 371, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A5OS8kEURZ8ZYZzfO7we0KvxaGI1AdMRZ.L6C2S4%2B6rTQd5qdQufDhV9rDv9CJgENLudOAk9h0Yow", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:33 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "43" + }, + { + "name": "ETag", + "value": "W/\"2b-NI+s6dhyoOC4+MmZW5sCBgzsnMw\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3A5OS8kEURZ8ZYZzfO7we0KvxaGI1AdMRZ.L6C2S4%2B6rTQd5qdQufDhV9rDv9CJgENLudOAk9h0Yow; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 43, + "mimeType": "application/json; charset=utf-8", + "text": "eyJzdW0iOiJTYXQgT2N0IDEwIDIxMTYgMDA6MDA6MDAgR01UKzAwMDAifQ==", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 43 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 937, + "ssl": 637, + "send": 0, + "wait": 285, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:33.126+08:00", + "time": 1209, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/subtract?timestamp=2016-10-10&years=50", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A5OS8kEURZ8ZYZzfO7we0KvxaGI1AdMRZ.L6C2S4%2B6rTQd5qdQufDhV9rDv9CJgENLudOAk9h0Yow" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "d903ee32-4361-44a4-af56-819e7fa10cc4" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3A5OS8kEURZ8ZYZzfO7we0KvxaGI1AdMRZ.L6C2S4%2B6rTQd5qdQufDhV9rDv9CJgENLudOAk9h0Yow" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + }, + { + "name": "years", + "value": "50" + } + ], + "headersSize": 373, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A2PKCLJCVRo_5V_uagkV5b3Kn9dV0eQUm.Dp5OFZ%2FCtOcDKqB8y8yywFHO6LbN9oe10o4DQ%2FnoKRk", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:34 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "50" + }, + { + "name": "ETag", + "value": "W/\"32-PND5PkDaCj18RICDpWcSi9vkakY\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3A2PKCLJCVRo_5V_uagkV5b3Kn9dV0eQUm.Dp5OFZ%2FCtOcDKqB8y8yywFHO6LbN9oe10o4DQ%2FnoKRk; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 50, + "mimeType": "application/json; charset=utf-8", + "text": "eyJkaWZmZXJlbmNlIjoiTW9uIE9jdCAxMCAxOTY2IDAwOjAwOjAwIEdNVCswMDAwIn0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 50 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 868, + "ssl": 572, + "send": 0, + "wait": 339, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:34.370+08:00", + "time": 1298, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/start?timestamp=2016-10-10&unit=month", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3A2PKCLJCVRo_5V_uagkV5b3Kn9dV0eQUm.Dp5OFZ%2FCtOcDKqB8y8yywFHO6LbN9oe10o4DQ%2FnoKRk" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "2d666d32-2815-45be-ae8d-266eea519043" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3A2PKCLJCVRo_5V_uagkV5b3Kn9dV0eQUm.Dp5OFZ%2FCtOcDKqB8y8yywFHO6LbN9oe10o4DQ%2FnoKRk" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + }, + { + "name": "unit", + "value": "month" + } + ], + "headersSize": 374, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AWJZnlAAItW8H8a4UMGox8Iz7cv3TM5Zq.YRYNuDnd6fkHDDvlbilW9q4AkvSPwE8SsBs2JRC52HU", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:35 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "45" + }, + { + "name": "ETag", + "value": "W/\"2d-+DRNEGBPVvAa16PUC5AjHCOmq/0\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AWJZnlAAItW8H8a4UMGox8Iz7cv3TM5Zq.YRYNuDnd6fkHDDvlbilW9q4AkvSPwE8SsBs2JRC52HU; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 45, + "mimeType": "application/json; charset=utf-8", + "text": "eyJzdGFydCI6IlNhdCBPY3QgMDEgMjAxNiAwMDowMDowMCBHTVQrMDAwMCJ9", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 45 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 893, + "ssl": 608, + "send": 0, + "wait": 403, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:35.701+08:00", + "time": 1137, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/object?timestamp=2016-10-10", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AWJZnlAAItW8H8a4UMGox8Iz7cv3TM5Zq.YRYNuDnd6fkHDDvlbilW9q4AkvSPwE8SsBs2JRC52HU" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "6ecae5c7-b9b4-450d-865c-10aea2f6384c" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AWJZnlAAItW8H8a4UMGox8Iz7cv3TM5Zq.YRYNuDnd6fkHDDvlbilW9q4AkvSPwE8SsBs2JRC52HU" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + } + ], + "headersSize": 360, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AJSsXggdxTpnvv6WVFqDrJ8Sjeuu77nE4.IcUuska8iBP1lkpKISqwIPOaqy5qLB%2F2o8v2Txs%2F5f8", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:37 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "86" + }, + { + "name": "ETag", + "value": "W/\"56-sbJq4ZMpg65IM+Xxb5GSE9GGvQc\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AJSsXggdxTpnvv6WVFqDrJ8Sjeuu77nE4.IcUuska8iBP1lkpKISqwIPOaqy5qLB%2F2o8v2Txs%2F5f8; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 86, + "mimeType": "application/json; charset=utf-8", + "text": "eyJ5ZWFycyI6MjAxNiwibW9udGhzIjo5LCJkYXRlIjoxMCwiaG91cnMiOjAsIm1pbnV0ZXMiOjAsInNlY29uZHMiOjAsIm1pbGxpc2Vjb25kcyI6MH0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 86 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 847, + "ssl": 568, + "send": 0, + "wait": 284, + "receive": 5 + } + }, + { + "startedDateTime": "2021-10-16T15:05:36.869+08:00", + "time": 1156, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/before?timestamp=2016-10-10&target=2017-10-10", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AJSsXggdxTpnvv6WVFqDrJ8Sjeuu77nE4.IcUuska8iBP1lkpKISqwIPOaqy5qLB%2F2o8v2Txs%2F5f8" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "faaa8cb6-13c5-4d0c-a7d2-133520637dde" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AJSsXggdxTpnvv6WVFqDrJ8Sjeuu77nE4.IcUuska8iBP1lkpKISqwIPOaqy5qLB%2F2o8v2Txs%2F5f8" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + }, + { + "name": "target", + "value": "2017-10-10" + } + ], + "headersSize": 382, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AQ9JCfRzQhaoMt6eD7gx_qk3JQ8CWnAxO.g3tHBGmTN8Vc1mqWWnSqGV1VOQdmKk8HG3z29e%2FBzhA", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:38 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "15" + }, + { + "name": "ETag", + "value": "W/\"f-pYji1tDlxSR6vlOQLH4azAZGkpo\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AQ9JCfRzQhaoMt6eD7gx_qk3JQ8CWnAxO.g3tHBGmTN8Vc1mqWWnSqGV1VOQdmKk8HG3z29e%2FBzhA; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 15, + "mimeType": "application/json; charset=utf-8", + "text": "eyJiZWZvcmUiOnRydWV9", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 15 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 850, + "ssl": 571, + "send": 0, + "wait": 304, + "receive": 1 + } + }, + { + "startedDateTime": "2021-10-16T15:05:38.058+08:00", + "time": 1296, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/after?timestamp=2016-10-10&target=2017-10-10", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AQ9JCfRzQhaoMt6eD7gx_qk3JQ8CWnAxO.g3tHBGmTN8Vc1mqWWnSqGV1VOQdmKk8HG3z29e%2FBzhA" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "28c6c8f1-bb76-4fce-986c-adc2fd5df80d" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AQ9JCfRzQhaoMt6eD7gx_qk3JQ8CWnAxO.g3tHBGmTN8Vc1mqWWnSqGV1VOQdmKk8HG3z29e%2FBzhA" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + }, + { + "name": "target", + "value": "2017-10-10" + } + ], + "headersSize": 379, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:39 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "15" + }, + { + "name": "ETag", + "value": "W/\"f-1yo7D9f7qelpng2aZyy3Vk9UAA8\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 15, + "mimeType": "application/json; charset=utf-8", + "text": "eyJhZnRlciI6ZmFsc2V9", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 15 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 906, + "ssl": 624, + "send": 0, + "wait": 389, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:39.392+08:00", + "time": 1129, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/between?timestamp=2016-10-10&start=2017-10-10&end=2019-10-10", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "32aaca4e-02a8-4559-9368-5705a1a65e19" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + }, + { + "name": "start", + "value": "2017-10-10" + }, + { + "name": "end", + "value": "2019-10-10" + } + ], + "headersSize": 397, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:40 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "17" + }, + { + "name": "ETag", + "value": "W/\"11-Q5jSDN8J9UWiS3bMKjaPflikNDU\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 17, + "mimeType": "application/json; charset=utf-8", + "text": "eyJiZXR3ZWVuIjpmYWxzZX0=", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 17 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 843, + "ssl": 565, + "send": 0, + "wait": 283, + "receive": 2 + } + }, + { + "startedDateTime": "2021-10-16T15:05:40.555+08:00", + "time": 1174, + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/leap?timestamp=2016-10-10", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "ff77428a-b157-463a-91e0-e5126d99d6c0" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI" + } + ], + "queryString": [ + { + "name": "timestamp", + "value": "2016-10-10" + } + ], + "headersSize": 362, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AhLPrbCV0ByxRorQusdRky8bws0S2qQjf.V4SIDOu%2BdIgGVSCA5qvRYwhi3xR%2Bd0R9gL9RDUPdpI4", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:41 GMT" + }, + { + "name": "Content-Type", + "value": "application/json; charset=utf-8" + }, + { + "name": "Content-Length", + "value": "13" + }, + { + "name": "ETag", + "value": "W/\"d-/cHbrs54NBQWs+BmYLn36yaGw/0\"" + }, + { + "name": "Vary", + "value": "Accept-Encoding" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3AhLPrbCV0ByxRorQusdRky8bws0S2qQjf.V4SIDOu%2BdIgGVSCA5qvRYwhi3xR%2Bd0R9gL9RDUPdpI4; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 13, + "mimeType": "application/json; charset=utf-8", + "text": "eyJsZWFwIjp0cnVlfQ==", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 13 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 849, + "ssl": 568, + "send": 0, + "wait": 324, + "receive": 0 + } + }, + { + "startedDateTime": "2021-10-16T15:05:41.763+08:00", + "time": 1378, + "request": { + "method": "GET", + "url": "https://postman-echo.com/digest-auth", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3AhLPrbCV0ByxRorQusdRky8bws0S2qQjf.V4SIDOu%2BdIgGVSCA5qvRYwhi3xR%2Bd0R9gL9RDUPdpI4" + } + ], + "headers": [ + { + "name": "User-Agent", + "value": "PostmanRuntime/7.28.4" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Cache-Control", + "value": "no-cache" + }, + { + "name": "Postman-Token", + "value": "8f6b453b-580c-44bc-8f9f-b2baa64ab530" + }, + { + "name": "Host", + "value": "postman-echo.com" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Cookie", + "value": "sails.sid=s%3AhLPrbCV0ByxRorQusdRky8bws0S2qQjf.V4SIDOu%2BdIgGVSCA5qvRYwhi3xR%2Bd0R9gL9RDUPdpI4" + } + ], + "queryString": [], + "headersSize": 343, + "bodySize": 0 + }, + "response": { + "_charlesStatus": "COMPLETE", + "status": 401, + "statusText": "Unauthorized", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "sails.sid", + "value": "s%3ACLdEI5FgDpez6LxwwGswSZNXbHEANDJJ.k3OW1SRe2w4ROpm83%2FNJ2xtPis%2FtcWVMsvX%2F3dUi3FE", + "path": "/", + "domain": null, + "expires": null, + "httpOnly": true, + "secure": false, + "comment": null, + "_maxAge": null + } + ], + "headers": [ + { + "name": "Date", + "value": "Sat, 16 Oct 2021 07:05:43 GMT" + }, + { + "name": "Transfer-Encoding", + "value": "chunked" + }, + { + "name": "WWW-Authenticate", + "value": "Digest realm=\"Users\", nonce=\"hWVYO1ts29HPxCpHoUhGVRzzsggQ3uCg\", qop=\"auth\"" + }, + { + "name": "set-cookie", + "value": "sails.sid=s%3ACLdEI5FgDpez6LxwwGswSZNXbHEANDJJ.k3OW1SRe2w4ROpm83%2FNJ2xtPis%2FtcWVMsvX%2F3dUi3FE; Path=/; HttpOnly" + }, + { + "name": "Connection", + "value": "keep-alive" + } + ], + "content": { + "size": 20, + "mimeType": null, + "text": "VW5hdXRob3JpemVk", + "encoding": "base64" + }, + "redirectURL": null, + "headersSize": 0, + "bodySize": 20 + }, + "serverIPAddress": "44.193.31.23", + "cache": {}, + "timings": { + "dns": 1, + "connect": 977, + "ssl": 696, + "send": 0, + "wait": 400, + "receive": 0 + } + } + ] + } +} \ No newline at end of file diff --git a/examples/hrp/har/postman-echo.yaml b/examples/hrp/har/postman-echo.yaml new file mode 100644 index 00000000..ea92ed12 --- /dev/null +++ b/examples/hrp/har/postman-echo.yaml @@ -0,0 +1,1101 @@ +config: + name: testcase description +teststeps: + - name: "" + request: + method: GET + url: https://postman-echo.com/get + params: + foo1: bar1 + foo2: bar2 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: ea19464c-ddd4-4724-abe9-5e2b254c2723 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3ASAXM8INphoz4_-5nCeQNBtrlsWuHs5Mt.83PsbOXUZUoPolzR2vpghXLUghDPLyA3NSrVKI8A8ws + 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=bar1&foo2=bar2 + msg: assert response body url + - name: "" + request: + method: POST + url: https://postman-echo.com/post + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Content-Length: "58" + Content-Type: text/plain + Host: postman-echo.com + Postman-Token: 40756814-a974-4fcc-98d2-1f2aec73c295 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3Ack89N2nb1AxU-T-nxvJrvOS1KvUXbiU2.3nAhh%2FjA%2F%2FNvHtWI8NApXa1QWV3hDD6LBsfUwpIdYQc + body: This is expected to be sent back as part of response body. + 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: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - 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: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Content-Length: "19" + Content-Type: application/x-www-form-urlencoded + Host: postman-echo.com + Postman-Token: 93843e50-2fe8-422d-b900-91095f9f0cdb + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A4bF7QNsgYKOBRnxJEclo-wiPIm6YxzFY.zmgnSBoVtZ3C40cBCJPsFS6KXTPoQBlKdS2FIdoxFaA + body: foo1=bar1&foo2=bar2 + 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 + - name: "" + request: + method: PUT + url: https://postman-echo.com/put + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Content-Length: "58" + Content-Type: text/plain + Host: postman-echo.com + Postman-Token: 5d357b2b-0f10-4ded-bc9a-299ebef7a2d5 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A7Kp8q3TlXZgZpLiLQNE4OGvpaqJwWmWX.SkW6gD2iyLO%2FFZYMAbg0bTsfuHwnEBezprz6nbykPWg + body: This is expected to be sent back as part of response body. + 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: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - check: body.url + assert: equals + expect: https://postman-echo.com/put + msg: assert response body url + - name: "" + request: + method: PATCH + url: https://postman-echo.com/patch + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Content-Length: "58" + Content-Type: text/plain + Host: postman-echo.com + Postman-Token: 27a30a79-5d88-43c0-8c83-fce5bb585729 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3ArMIVJXM1u78IGSzps0LYNjimloLEMdqk.6bzxgShLW4DTNlqRdZREK7OUV1kqu2kMHtEVxR9Xlyg + body: This is expected to be sent back as part of response body. + 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: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - check: body.url + assert: equals + expect: https://postman-echo.com/patch + msg: assert response body url + - name: "" + request: + method: DELETE + url: https://postman-echo.com/delete + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Content-Length: "58" + Content-Type: text/plain + Host: postman-echo.com + Postman-Token: b11f7819-4c39-41b3-9d06-696b38c3e515 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AlTv3pBzULeMHqjWpJWW-rwLZYYdqzSyW.J5YSZCf1unKehq5zNyuee%2B2xYkqoK%2BcTPTr3RzHYtYM + body: This is expected to be sent back as part of response body. + 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: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - check: body.url + assert: equals + expect: https://postman-echo.com/delete + msg: assert response body url + - name: "" + request: + method: GET + url: https://postman-echo.com/headers + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 1a4e2039-d29b-4ed7-89e9-584b354246be + User-Agent: PostmanRuntime/7.28.4 + my-sample-header: Lorem ipsum dolor sit amet + cookies: + sails.sid: s%3A6Sj7Mduyb72fC-X0OQbDmFqp77bVEgt8.b5X8H%2BtACzKfkUlH%2FBtSYH%2FdSQ5fHynzHjK8gE3s%2FpI + 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 + - name: "" + request: + method: GET + url: https://postman-echo.com/response-headers + params: + foo1: bar1 + foo2: bar2 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: b00d3c25-a84b-4152-bcf8-4c573c06024b + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AvvP5l4Bk7WCLBU9LNXalNk4w4x3Q_2Zi.JiGgykR8RlAGIdRWv%2FdCmCL0Tbmwyni9KkXXgnzn59s + 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.foo1 + assert: equals + expect: bar1 + msg: assert response body foo1 + - check: body.foo2 + assert: equals + expect: bar2 + msg: assert response body foo2 + - name: "" + request: + method: GET + url: https://postman-echo.com/basic-auth + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Authorization: Basic cG9zdG1hbjpwYXNzd29yZA== + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: d9f810a2-292d-41c4-95e1-ec9f9ae778d6 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3APA71Iib2-7KqjRMajldmUsDqOqmRDB6-.zpTeobSmlq81Z7R%2FyL7q3o8%2FAP0tfOOZSPQdBlirJ6g + 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.authenticated + assert: equals + expect: true + msg: assert response body authenticated + - name: "" + request: + method: GET + url: https://postman-echo.com/digest-auth + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Authorization: Digest username="postman", realm="Users", nonce="W7kT5VowsR0pcTfL9fTwZKv2tRdEiG6c", uri="/digest-auth", algorithm="MD5", response="bab1b1e6534f84b43e9deb17bca9371b" + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 42e8340a-852b-4c7a-ab7d-d0b027f044ca + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AT2IbNG9nLojvklvDr1mo2cCftGUgcAgU.f1XqnM5ebKiLtIs3CKYYvBo7j5iHwiP9EuG9i91RR%2FU + 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.authenticated + assert: equals + expect: true + msg: assert response body authenticated + - name: "" + request: + method: GET + url: https://postman-echo.com/auth/hawk + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Authorization: Hawk id="dh37fgj492je", ts="1634367906", nonce="RZKGNz", mac="EASK1an/9fmDhFJcqH8XE4pTuUaSJisuQVM+NCOjNlM=" + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 46645864-583c-446b-9d36-9610fb114d99 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AWyHRwAoLc64u8sF_LqU0BUYAieEguHiH.gb%2BNYX72g6n5lHjLdl5K1hsKmLHYJUwoOwKkDWVl7qY + 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.message + assert: equals + expect: Hawk Authentication Successful + msg: assert response body message + - name: "" + request: + method: GET + url: https://postman-echo.com/oauth1 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Authorization: OAuth oauth_consumer_key="RKCGzna7bv9YD57c",oauth_signature_method="HMAC-SHA1",oauth_timestamp="1634367907",oauth_nonce="pAoTV0k5VZa",oauth_signature="ZTkfsaUA1B2s7kyl3HaFm1zFow4%3D" + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 3d9db9bb-5bcf-425e-b0e4-a958c07d7969 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AZQRuQaIb28umtrzP-HOj4fSqeag88Pvj.KVLylhlYJ3JKMHUS0UVeLCT6qRcBgQl%2BM14UxI7EgQs + 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.message + assert: equals + expect: OAuth-1.0a signature verification was successful + msg: assert response body message + - check: body.status + assert: equals + expect: pass + msg: assert response body status + - name: "" + request: + method: GET + url: https://postman-echo.com/cookies/set + params: + foo1: bar1 + foo2: bar2 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: ff927796-58d3-4f43-8701-8411747c4313 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AsdmvN2_ZNE0YlwQY5GxY04ptWTOYR5NU.kkH0dnWlEMsblzPMurLX8nsQRRbRqLqteIhA0621onY + validate: + - check: status_code + assert: equals + expect: 302 + msg: assert response status code + - check: headers."Content-Type" + assert: equals + expect: text/plain; charset=utf-8 + msg: assert response header Content-Type + - name: "" + request: + method: GET + url: https://postman-echo.com/cookies + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: ff927796-58d3-4f43-8701-8411747c4313 + Referer: https://postman-echo.com/cookies/set?foo1=bar1&foo2=bar2 + User-Agent: PostmanRuntime/7.28.4 + cookies: + foo1: bar1 + foo2: bar2 + sails.sid: s%3AlVpTnkb0ofz6HC7QJMVtiRexW3u_onsT.rmsoerMcOQOu7KYPU80x%2FBiieqBESMNj%2FxuCvbbw%2BsQ + 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 + - name: "" + request: + method: GET + url: https://postman-echo.com/cookies + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 2dbc6d22-1713-4b96-a1a2-3358b1a1deaa + User-Agent: PostmanRuntime/7.28.4 + cookies: + foo1: bar1 + foo2: bar2 + sails.sid: s%3Avz13GzkqWaYvFuB3I35udi2vLsikZZgi.YgVWfqmyjPpEduyCIZDFGyDSPYY8%2FFM7HePC5Ok0hQM + 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 + - name: "" + request: + method: GET + url: https://postman-echo.com/cookies/delete + params: + foo1: "" + foo2: "" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 8837dd89-9db7-4f06-9187-e7a85a99b945 + User-Agent: PostmanRuntime/7.28.4 + cookies: + foo1: bar1 + foo2: bar2 + sails.sid: s%3AQ8MT5sT-2LAO0Rk7bNLLR18UQWgaJMsg.eOEyhDjqWGwn2rdqWeGLstPmrn5H1OUZGlDLuI%2F1Nng + validate: + - check: status_code + assert: equals + expect: 302 + msg: assert response status code + - check: headers."Content-Type" + assert: equals + expect: text/plain; charset=utf-8 + msg: assert response header Content-Type + - name: "" + request: + method: GET + url: https://postman-echo.com/cookies + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 8837dd89-9db7-4f06-9187-e7a85a99b945 + Referer: https://postman-echo.com/cookies/delete?foo1&foo2 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A1atMUPWbEEDiMqdbTqbddbqiFujSi1l2.6n40eqlOkTsKoB6K7xT98PrfQweiPlTjJTfZl%2FpAEsU + 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 + - name: "" + request: + method: GET + url: https://postman-echo.com/status/200 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 5f4c6d97-d476-407e-bbf9-532480f618d8 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A5p9FN9UVGZ9XJl6I9FXiz0AwIQRRU1ka.RFuMLR9arGQaLkM1gbvuPosvzPxsREHGEjjiVF4TXnQ + 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.status + assert: equals + expect: 200 + msg: assert response body status + - name: "" + request: + method: GET + url: https://postman-echo.com/stream/5 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 24ca01aa-6c3f-4a78-a437-33dfa8dadd0f + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AFD7Hy01JAAenWz9SoQQhJxH4Qxel9sbP.%2Ba5JmTwqOpkc%2FAOLOzzsfStpK2MTfZCYXiCoA39Zt7w + validate: + - check: status_code + assert: equals + expect: 200 + msg: assert response status code + - name: "" + request: + method: GET + url: https://postman-echo.com/delay/2 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: d2ade32f-4bb8-4e6d-90d3-5fa7560def12 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AqSePO9_VmCbBbVvsCMYMHm3lShKdFNWU.RFuwKJdlZHVyB0gF1x2Yt78v5jKbese6f8HNPIjI5AY + 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.delay + assert: equals + expect: "2" + msg: assert response body delay + - name: "" + request: + method: GET + url: https://postman-echo.com/encoding/utf8 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: bd39f8e4-8072-4ec3-b498-3aaacb621544 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AXrCX-GaGzqizPQY2AdLTLNPO_cFgVsGD.BwOoj2gClsAzDrsP0%2FObypcumuYCfV%2F4vHCrKIWdTAQ + validate: + - check: status_code + assert: equals + expect: 200 + msg: assert response status code + - check: headers."Content-Type" + assert: equals + expect: text/html; charset=utf-8 + msg: assert response header Content-Type + - name: "" + request: + method: GET + url: https://postman-echo.com/gzip + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: ef40db18-75f9-4d0c-9fe8-94274a0a589e + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU + 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.gzipped + assert: equals + expect: true + msg: assert response body gzipped + - check: body.method + assert: equals + expect: GET + msg: assert response body method + - name: "" + request: + method: GET + url: https://postman-echo.com/deflate + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 06b47e94-9131-4ab7-8d0e-d0990f1a1144 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU + 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.deflated + assert: equals + expect: true + msg: assert response body deflated + - check: body.method + assert: equals + expect: GET + msg: assert response body method + - name: "" + request: + method: GET + url: https://postman-echo.com/ip + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 246c423e-9285-4fad-b471-434bf4bf3369 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A_sZ_Nn5QQ0b2Swfp9tMHX9CWKJb9X3is.fa%2FQ9D9WhuFBgpatC2Yo33cPynch4YqbG%2Fw9iB92Jxo + 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.ip + assert: equals + expect: 122.14.229.79 + msg: assert response body ip + - name: "" + request: + method: GET + url: https://postman-echo.com/time/now + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: e1107fa9-80cb-4e69-b3dd-6fd0c92832b1 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs + validate: + - check: status_code + assert: equals + expect: 200 + msg: assert response status code + - check: headers."Content-Type" + assert: equals + expect: text/html; charset=utf-8 + msg: assert response header Content-Type + - name: "" + request: + method: GET + url: https://postman-echo.com/time/valid + params: + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 05eb8403-8a83-4bde-bdd4-67952910c00f + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs + 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.valid + assert: equals + expect: true + msg: assert response body valid + - name: "" + request: + method: GET + url: https://postman-echo.com/time/format + params: + format: mm + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 7bab6bdc-6fe5-4eb8-aff0-3cfa08e5a823 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3Ai_9yOOqBlD9Nq0-5kptXL_qLhgITKpaZ.HU5sTJC0jVIzJvykONaDFYTiMZrZpQgdiwMInhSADss + 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.format + assert: equals + expect: "20" + msg: assert response body format + - name: "" + request: + method: GET + url: https://postman-echo.com/time/unit + params: + timestamp: "2016-10-10" + unit: day + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 8dbb7595-3ff0-47cd-8883-4c1f24a840ef + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8 + 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.unit + assert: equals + expect: 1 + msg: assert response body unit + - name: "" + request: + method: GET + url: https://postman-echo.com/time/add + params: + timestamp: "2016-10-10" + years: "100" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 12c5137f-ee8e-48c2-b1b7-99c85f0667e4 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8 + 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.sum + assert: equals + expect: Sat Oct 10 2116 00:00:00 GMT+0000 + msg: assert response body sum + - name: "" + request: + method: GET + url: https://postman-echo.com/time/subtract + params: + timestamp: "2016-10-10" + years: "50" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: d903ee32-4361-44a4-af56-819e7fa10cc4 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A5OS8kEURZ8ZYZzfO7we0KvxaGI1AdMRZ.L6C2S4%2B6rTQd5qdQufDhV9rDv9CJgENLudOAk9h0Yow + 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.difference + assert: equals + expect: Mon Oct 10 1966 00:00:00 GMT+0000 + msg: assert response body difference + - name: "" + request: + method: GET + url: https://postman-echo.com/time/start + params: + timestamp: "2016-10-10" + unit: month + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 2d666d32-2815-45be-ae8d-266eea519043 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A2PKCLJCVRo_5V_uagkV5b3Kn9dV0eQUm.Dp5OFZ%2FCtOcDKqB8y8yywFHO6LbN9oe10o4DQ%2FnoKRk + 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.start + assert: equals + expect: Sat Oct 01 2016 00:00:00 GMT+0000 + msg: assert response body start + - name: "" + request: + method: GET + url: https://postman-echo.com/time/object + params: + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 6ecae5c7-b9b4-450d-865c-10aea2f6384c + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AWJZnlAAItW8H8a4UMGox8Iz7cv3TM5Zq.YRYNuDnd6fkHDDvlbilW9q4AkvSPwE8SsBs2JRC52HU + 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.date + assert: equals + expect: 10 + msg: assert response body date + - check: body.hours + assert: equals + expect: 0 + msg: assert response body hours + - check: body.milliseconds + assert: equals + expect: 0 + msg: assert response body milliseconds + - check: body.minutes + assert: equals + expect: 0 + msg: assert response body minutes + - check: body.months + assert: equals + expect: 9 + msg: assert response body months + - check: body.seconds + assert: equals + expect: 0 + msg: assert response body seconds + - check: body.years + assert: equals + expect: 2016 + msg: assert response body years + - name: "" + request: + method: GET + url: https://postman-echo.com/time/before + params: + target: "2017-10-10" + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: faaa8cb6-13c5-4d0c-a7d2-133520637dde + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AJSsXggdxTpnvv6WVFqDrJ8Sjeuu77nE4.IcUuska8iBP1lkpKISqwIPOaqy5qLB%2F2o8v2Txs%2F5f8 + 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.before + assert: equals + expect: true + msg: assert response body before + - name: "" + request: + method: GET + url: https://postman-echo.com/time/after + params: + target: "2017-10-10" + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 28c6c8f1-bb76-4fce-986c-adc2fd5df80d + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AQ9JCfRzQhaoMt6eD7gx_qk3JQ8CWnAxO.g3tHBGmTN8Vc1mqWWnSqGV1VOQdmKk8HG3z29e%2FBzhA + 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.after + assert: equals + expect: false + msg: assert response body after + - name: "" + request: + method: GET + url: https://postman-echo.com/time/between + params: + end: "2019-10-10" + start: "2017-10-10" + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 32aaca4e-02a8-4559-9368-5705a1a65e19 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI + 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.between + assert: equals + expect: false + msg: assert response body between + - name: "" + request: + method: GET + url: https://postman-echo.com/time/leap + params: + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: ff77428a-b157-463a-91e0-e5126d99d6c0 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI + 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.leap + assert: equals + expect: true + msg: assert response body leap + - name: "" + request: + method: GET + url: https://postman-echo.com/digest-auth + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 8f6b453b-580c-44bc-8f9f-b2baa64ab530 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AhLPrbCV0ByxRorQusdRky8bws0S2qQjf.V4SIDOu%2BdIgGVSCA5qvRYwhi3xR%2Bd0R9gL9RDUPdpI4 + validate: + - check: status_code + assert: equals + expect: 401 + msg: assert response status code diff --git a/examples/hrp/httpbin.json b/examples/hrp/httpbin.json new file mode 100644 index 00000000..2bc11130 --- /dev/null +++ b/examples/hrp/httpbin.json @@ -0,0 +1,51 @@ +{ + "config": { + "name": "testcase description", + "variables": {}, + "verify": false + }, + "teststeps": [ + { + "name": "/get", + "request": { + "url": "http://httpbin.org/get", + "method": "GET", + "headers": { + "Host": "httpbin.org", + "Connection": "keep-alive", + "accept": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36 Edg/98.0.1108.50", + "Referer": "http://httpbin.org/", + "Accept-Encoding": "gzip, deflate", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "assert response status code" + }, + { + "check": "headers.\"Content-Type\"", + "assert": "equals", + "expect": "application/json", + "msg": "assert response header Content-Type" + }, + { + "check": "body.origin", + "assert": "equals", + "expect": "117.176.133.109", + "msg": "assert response body origin" + }, + { + "check": "body.url", + "assert": "equals", + "expect": "http://httpbin.org/get", + "msg": "assert response body url" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/hrp/parameters_test.json b/examples/hrp/parameters_test.json new file mode 100644 index 00000000..f379f326 --- /dev/null +++ b/examples/hrp/parameters_test.json @@ -0,0 +1,61 @@ +{ + "config": { + "name": "request methods testcase: validate with parameters", + "parameters": { + "user_agent": [ + "iOS/10.1", + "iOS/10.2" + ], + "username-password": "${parameterize(examples/hrp/account.csv)}" + }, + "parameters_setting": { + "strategy": { + "user_agent": "sequential", + "username-password": "random" + }, + "iteration": 6 + }, + "variables": { + "app_version": "v1", + "user_agent": "iOS/10.3" + }, + "base_url": "https://postman-echo.com", + "verify": false + }, + "teststeps": [ + { + "name": "get with params", + "variables": { + "foo1": "$username", + "foo2": "$password", + "foo3": "$user_agent" + }, + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "$foo1", + "foo2": "$foo2", + "foo3": "$foo3" + }, + "headers": { + "User-Agent": "$user_agent,$app_version" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "check status code" + }, + { + "check": "body.args.foo3", + "assert": "not_equal", + "expect": "iOS/10.3", + "msg": "check app version" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/hrp/parameters_test.yaml b/examples/hrp/parameters_test.yaml new file mode 100644 index 00000000..7c3ab523 --- /dev/null +++ b/examples/hrp/parameters_test.yaml @@ -0,0 +1,40 @@ +config: + name: "request methods testcase: validate with parameters" + parameters: + user_agent: [ "iOS/10.1", "iOS/10.2" ] + username-password: ${parameterize(examples/hrp/account.csv)} + parameters_setting: + strategy: + user_agent: "sequential" + username-password: "random" + iteration: 6 + variables: + app_version: v1 + user_agent: iOS/10.3 + base_url: "https://postman-echo.com" + verify: False + +teststeps: + - name: get with params + variables: + foo1: $username + foo2: $password + foo3: $user_agent + request: + method: GET + url: /get + params: + foo1: $foo1 + foo2: $foo2 + foo3: $foo3 + headers: + User-Agent: $user_agent,$app_version + validate: + - check: status_code + assert: equals + expect: 200 + msg: check status code + - check: body.args.foo3 + assert: not_equal + expect: iOS/10.3 + msg: check app version \ No newline at end of file diff --git a/examples/hrp/plugin/debugtalk.go b/examples/hrp/plugin/debugtalk.go new file mode 100644 index 00000000..64cdc946 --- /dev/null +++ b/examples/hrp/plugin/debugtalk.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "log" +) + +func init() { + log.Println("plugin init function called") +} + +func SumTwoInt(a, b int) int { + return a + b +} + +func SumInts(args ...int) int { + var sum int + for _, arg := range args { + sum += arg + } + return sum +} + +func Sum(args ...interface{}) (interface{}, error) { + var sum float64 + for _, arg := range args { + switch v := arg.(type) { + case int: + sum += float64(v) + case float64: + sum += v + default: + return nil, fmt.Errorf("unexpected type: %T", arg) + } + } + return sum, nil +} + +func SumTwoString(a, b string) string { + return a + b +} + +func SumStrings(s ...string) string { + var sum string + for _, arg := range s { + sum += arg + } + return sum +} + +func Concatenate(args ...interface{}) (interface{}, error) { + var result string + for _, arg := range args { + result += fmt.Sprintf("%v", arg) + } + return result, nil +} + +func SetupHookExample(args string) string { + return fmt.Sprintf("step name: %v, setup...", args) +} + +func TeardownHookExample(args string) string { + return fmt.Sprintf("step name: %v, teardown...", args) +} diff --git a/examples/hrp/plugin/hashicorp.go b/examples/hrp/plugin/hashicorp.go new file mode 100644 index 00000000..4d09f339 --- /dev/null +++ b/examples/hrp/plugin/hashicorp.go @@ -0,0 +1,18 @@ +package main + +import ( + "github.com/httprunner/funplugin/fungo" +) + +// register functions and build to plugin binary +func main() { + fungo.Register("sum_ints", SumInts) + fungo.Register("sum_two_int", SumTwoInt) + fungo.Register("sum", Sum) + fungo.Register("sum_two_string", SumTwoString) + fungo.Register("sum_strings", SumStrings) + fungo.Register("concatenate", Concatenate) + fungo.Register("setup_hook_example", SetupHookExample) + fungo.Register("teardown_hook_example", TeardownHookExample) + fungo.Serve() +} diff --git a/examples/hrp/postman-echo.json b/examples/hrp/postman-echo.json new file mode 100644 index 00000000..50294992 --- /dev/null +++ b/examples/hrp/postman-echo.json @@ -0,0 +1,1578 @@ +{ + "config": { + "name": "testcase description" + }, + "teststeps": [ + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/get", + "params": { + "foo1": "bar1", + "foo2": "bar2" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b254c2723", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3ASAXM8INphoz4_-5nCeQNBtrlsWuHs5Mt.83PsbOXUZUoPolzR2vpghXLUghDPLyA3NSrVKI8A8ws" + } + }, + "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=bar1\u0026foo2=bar2", + "msg": "assert response body url" + } + ] + }, + { + "name": "", + "request": { + "method": "POST", + "url": "https://postman-echo.com/post", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Length": "58", + "Content-Type": "text/plain", + "Host": "postman-echo.com", + "Postman-Token": "40756814-a974-4fcc-98d2-1f2aec73c295", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3Ack89N2nb1AxU-T-nxvJrvOS1KvUXbiU2.3nAhh%2FjA%2F%2FNvHtWI8NApXa1QWV3hDD6LBsfUwpIdYQc" + }, + "body": "This is expected to be sent back as part of response body." + }, + "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": "This is expected to be sent back as part of response body.", + "msg": "assert response body data" + }, + { + "check": "body.json", + "assert": "equals", + "expect": null, + "msg": "assert response body json" + }, + { + "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": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Length": "19", + "Content-Type": "application/x-www-form-urlencoded", + "Host": "postman-echo.com", + "Postman-Token": "93843e50-2fe8-422d-b900-91095f9f0cdb", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3A4bF7QNsgYKOBRnxJEclo-wiPIm6YxzFY.zmgnSBoVtZ3C40cBCJPsFS6KXTPoQBlKdS2FIdoxFaA" + }, + "body": "foo1=bar1\u0026foo2=bar2" + }, + "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" + } + ] + }, + { + "name": "", + "request": { + "method": "PUT", + "url": "https://postman-echo.com/put", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Length": "58", + "Content-Type": "text/plain", + "Host": "postman-echo.com", + "Postman-Token": "5d357b2b-0f10-4ded-bc9a-299ebef7a2d5", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3A7Kp8q3TlXZgZpLiLQNE4OGvpaqJwWmWX.SkW6gD2iyLO%2FFZYMAbg0bTsfuHwnEBezprz6nbykPWg" + }, + "body": "This is expected to be sent back as part of response body." + }, + "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": "This is expected to be sent back as part of response body.", + "msg": "assert response body data" + }, + { + "check": "body.json", + "assert": "equals", + "expect": null, + "msg": "assert response body json" + }, + { + "check": "body.url", + "assert": "equals", + "expect": "https://postman-echo.com/put", + "msg": "assert response body url" + } + ] + }, + { + "name": "", + "request": { + "method": "PATCH", + "url": "https://postman-echo.com/patch", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Length": "58", + "Content-Type": "text/plain", + "Host": "postman-echo.com", + "Postman-Token": "27a30a79-5d88-43c0-8c83-fce5bb585729", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3ArMIVJXM1u78IGSzps0LYNjimloLEMdqk.6bzxgShLW4DTNlqRdZREK7OUV1kqu2kMHtEVxR9Xlyg" + }, + "body": "This is expected to be sent back as part of response body." + }, + "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": "This is expected to be sent back as part of response body.", + "msg": "assert response body data" + }, + { + "check": "body.json", + "assert": "equals", + "expect": null, + "msg": "assert response body json" + }, + { + "check": "body.url", + "assert": "equals", + "expect": "https://postman-echo.com/patch", + "msg": "assert response body url" + } + ] + }, + { + "name": "", + "request": { + "method": "DELETE", + "url": "https://postman-echo.com/delete", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Length": "58", + "Content-Type": "text/plain", + "Host": "postman-echo.com", + "Postman-Token": "b11f7819-4c39-41b3-9d06-696b38c3e515", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AlTv3pBzULeMHqjWpJWW-rwLZYYdqzSyW.J5YSZCf1unKehq5zNyuee%2B2xYkqoK%2BcTPTr3RzHYtYM" + }, + "body": "This is expected to be sent back as part of response body." + }, + "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": "This is expected to be sent back as part of response body.", + "msg": "assert response body data" + }, + { + "check": "body.json", + "assert": "equals", + "expect": null, + "msg": "assert response body json" + }, + { + "check": "body.url", + "assert": "equals", + "expect": "https://postman-echo.com/delete", + "msg": "assert response body url" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/headers", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "1a4e2039-d29b-4ed7-89e9-584b354246be", + "User-Agent": "PostmanRuntime/7.28.4", + "my-sample-header": "Lorem ipsum dolor sit amet" + }, + "cookies": { + "sails.sid": "s%3A6Sj7Mduyb72fC-X0OQbDmFqp77bVEgt8.b5X8H%2BtACzKfkUlH%2FBtSYH%2FdSQ5fHynzHjK8gE3s%2FpI" + } + }, + "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" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/response-headers", + "params": { + "foo1": "bar1", + "foo2": "bar2" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "b00d3c25-a84b-4152-bcf8-4c573c06024b", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AvvP5l4Bk7WCLBU9LNXalNk4w4x3Q_2Zi.JiGgykR8RlAGIdRWv%2FdCmCL0Tbmwyni9KkXXgnzn59s" + } + }, + "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.foo1", + "assert": "equals", + "expect": "bar1", + "msg": "assert response body foo1" + }, + { + "check": "body.foo2", + "assert": "equals", + "expect": "bar2", + "msg": "assert response body foo2" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/basic-auth", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Authorization": "Basic cG9zdG1hbjpwYXNzd29yZA==", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "d9f810a2-292d-41c4-95e1-ec9f9ae778d6", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3APA71Iib2-7KqjRMajldmUsDqOqmRDB6-.zpTeobSmlq81Z7R%2FyL7q3o8%2FAP0tfOOZSPQdBlirJ6g" + } + }, + "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.authenticated", + "assert": "equals", + "expect": true, + "msg": "assert response body authenticated" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/digest-auth", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Authorization": "Digest username=\"postman\", realm=\"Users\", nonce=\"W7kT5VowsR0pcTfL9fTwZKv2tRdEiG6c\", uri=\"/digest-auth\", algorithm=\"MD5\", response=\"bab1b1e6534f84b43e9deb17bca9371b\"", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "42e8340a-852b-4c7a-ab7d-d0b027f044ca", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AT2IbNG9nLojvklvDr1mo2cCftGUgcAgU.f1XqnM5ebKiLtIs3CKYYvBo7j5iHwiP9EuG9i91RR%2FU" + } + }, + "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.authenticated", + "assert": "equals", + "expect": true, + "msg": "assert response body authenticated" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/auth/hawk", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Authorization": "Hawk id=\"dh37fgj492je\", ts=\"1634367906\", nonce=\"RZKGNz\", mac=\"EASK1an/9fmDhFJcqH8XE4pTuUaSJisuQVM+NCOjNlM=\"", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "46645864-583c-446b-9d36-9610fb114d99", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AWyHRwAoLc64u8sF_LqU0BUYAieEguHiH.gb%2BNYX72g6n5lHjLdl5K1hsKmLHYJUwoOwKkDWVl7qY" + } + }, + "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.message", + "assert": "equals", + "expect": "Hawk Authentication Successful", + "msg": "assert response body message" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/oauth1", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Authorization": "OAuth oauth_consumer_key=\"RKCGzna7bv9YD57c\",oauth_signature_method=\"HMAC-SHA1\",oauth_timestamp=\"1634367907\",oauth_nonce=\"pAoTV0k5VZa\",oauth_signature=\"ZTkfsaUA1B2s7kyl3HaFm1zFow4%3D\"", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "3d9db9bb-5bcf-425e-b0e4-a958c07d7969", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AZQRuQaIb28umtrzP-HOj4fSqeag88Pvj.KVLylhlYJ3JKMHUS0UVeLCT6qRcBgQl%2BM14UxI7EgQs" + } + }, + "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.message", + "assert": "equals", + "expect": "OAuth-1.0a signature verification was successful", + "msg": "assert response body message" + }, + { + "check": "body.status", + "assert": "equals", + "expect": "pass", + "msg": "assert response body status" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/cookies/set", + "params": { + "foo1": "bar1", + "foo2": "bar2" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "ff927796-58d3-4f43-8701-8411747c4313", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AsdmvN2_ZNE0YlwQY5GxY04ptWTOYR5NU.kkH0dnWlEMsblzPMurLX8nsQRRbRqLqteIhA0621onY" + } + }, + "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" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/cookies", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "ff927796-58d3-4f43-8701-8411747c4313", + "Referer": "https://postman-echo.com/cookies/set?foo1=bar1\u0026foo2=bar2", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "foo1": "bar1", + "foo2": "bar2", + "sails.sid": "s%3AlVpTnkb0ofz6HC7QJMVtiRexW3u_onsT.rmsoerMcOQOu7KYPU80x%2FBiieqBESMNj%2FxuCvbbw%2BsQ" + } + }, + "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" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/cookies", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "2dbc6d22-1713-4b96-a1a2-3358b1a1deaa", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "foo1": "bar1", + "foo2": "bar2", + "sails.sid": "s%3Avz13GzkqWaYvFuB3I35udi2vLsikZZgi.YgVWfqmyjPpEduyCIZDFGyDSPYY8%2FFM7HePC5Ok0hQM" + } + }, + "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" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/cookies/delete", + "params": { + "foo1": "", + "foo2": "" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "8837dd89-9db7-4f06-9187-e7a85a99b945", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "foo1": "bar1", + "foo2": "bar2", + "sails.sid": "s%3AQ8MT5sT-2LAO0Rk7bNLLR18UQWgaJMsg.eOEyhDjqWGwn2rdqWeGLstPmrn5H1OUZGlDLuI%2F1Nng" + } + }, + "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" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/cookies", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "8837dd89-9db7-4f06-9187-e7a85a99b945", + "Referer": "https://postman-echo.com/cookies/delete?foo1\u0026foo2", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3A1atMUPWbEEDiMqdbTqbddbqiFujSi1l2.6n40eqlOkTsKoB6K7xT98PrfQweiPlTjJTfZl%2FpAEsU" + } + }, + "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" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/status/200", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "5f4c6d97-d476-407e-bbf9-532480f618d8", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3A5p9FN9UVGZ9XJl6I9FXiz0AwIQRRU1ka.RFuMLR9arGQaLkM1gbvuPosvzPxsREHGEjjiVF4TXnQ" + } + }, + "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.status", + "assert": "equals", + "expect": 200, + "msg": "assert response body status" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/stream/5", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "24ca01aa-6c3f-4a78-a437-33dfa8dadd0f", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AFD7Hy01JAAenWz9SoQQhJxH4Qxel9sbP.%2Ba5JmTwqOpkc%2FAOLOzzsfStpK2MTfZCYXiCoA39Zt7w" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "assert response status code" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/delay/2", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "d2ade32f-4bb8-4e6d-90d3-5fa7560def12", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AqSePO9_VmCbBbVvsCMYMHm3lShKdFNWU.RFuwKJdlZHVyB0gF1x2Yt78v5jKbese6f8HNPIjI5AY" + } + }, + "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.delay", + "assert": "equals", + "expect": "2", + "msg": "assert response body delay" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/encoding/utf8", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "bd39f8e4-8072-4ec3-b498-3aaacb621544", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AXrCX-GaGzqizPQY2AdLTLNPO_cFgVsGD.BwOoj2gClsAzDrsP0%2FObypcumuYCfV%2F4vHCrKIWdTAQ" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "assert response status code" + }, + { + "check": "headers.\"Content-Type\"", + "assert": "equals", + "expect": "text/html; charset=utf-8", + "msg": "assert response header Content-Type" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/gzip", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "ef40db18-75f9-4d0c-9fe8-94274a0a589e", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU" + } + }, + "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.gzipped", + "assert": "equals", + "expect": true, + "msg": "assert response body gzipped" + }, + { + "check": "body.method", + "assert": "equals", + "expect": "GET", + "msg": "assert response body method" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/deflate", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "06b47e94-9131-4ab7-8d0e-d0990f1a1144", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU" + } + }, + "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.deflated", + "assert": "equals", + "expect": true, + "msg": "assert response body deflated" + }, + { + "check": "body.method", + "assert": "equals", + "expect": "GET", + "msg": "assert response body method" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/ip", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "246c423e-9285-4fad-b471-434bf4bf3369", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3A_sZ_Nn5QQ0b2Swfp9tMHX9CWKJb9X3is.fa%2FQ9D9WhuFBgpatC2Yo33cPynch4YqbG%2Fw9iB92Jxo" + } + }, + "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.ip", + "assert": "equals", + "expect": "122.14.229.79", + "msg": "assert response body ip" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/now", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "e1107fa9-80cb-4e69-b3dd-6fd0c92832b1", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "assert response status code" + }, + { + "check": "headers.\"Content-Type\"", + "assert": "equals", + "expect": "text/html; charset=utf-8", + "msg": "assert response header Content-Type" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/valid", + "params": { + "timestamp": "2016-10-10" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "05eb8403-8a83-4bde-bdd4-67952910c00f", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs" + } + }, + "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.valid", + "assert": "equals", + "expect": true, + "msg": "assert response body valid" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/format", + "params": { + "format": "mm", + "timestamp": "2016-10-10" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "7bab6bdc-6fe5-4eb8-aff0-3cfa08e5a823", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3Ai_9yOOqBlD9Nq0-5kptXL_qLhgITKpaZ.HU5sTJC0jVIzJvykONaDFYTiMZrZpQgdiwMInhSADss" + } + }, + "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.format", + "assert": "equals", + "expect": "20", + "msg": "assert response body format" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/unit", + "params": { + "timestamp": "2016-10-10", + "unit": "day" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "8dbb7595-3ff0-47cd-8883-4c1f24a840ef", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8" + } + }, + "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.unit", + "assert": "equals", + "expect": 1, + "msg": "assert response body unit" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/add", + "params": { + "timestamp": "2016-10-10", + "years": "100" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "12c5137f-ee8e-48c2-b1b7-99c85f0667e4", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8" + } + }, + "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.sum", + "assert": "equals", + "expect": "Sat Oct 10 2116 00:00:00 GMT+0000", + "msg": "assert response body sum" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/subtract", + "params": { + "timestamp": "2016-10-10", + "years": "50" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "d903ee32-4361-44a4-af56-819e7fa10cc4", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3A5OS8kEURZ8ZYZzfO7we0KvxaGI1AdMRZ.L6C2S4%2B6rTQd5qdQufDhV9rDv9CJgENLudOAk9h0Yow" + } + }, + "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.difference", + "assert": "equals", + "expect": "Mon Oct 10 1966 00:00:00 GMT+0000", + "msg": "assert response body difference" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/start", + "params": { + "timestamp": "2016-10-10", + "unit": "month" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "2d666d32-2815-45be-ae8d-266eea519043", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3A2PKCLJCVRo_5V_uagkV5b3Kn9dV0eQUm.Dp5OFZ%2FCtOcDKqB8y8yywFHO6LbN9oe10o4DQ%2FnoKRk" + } + }, + "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.start", + "assert": "equals", + "expect": "Sat Oct 01 2016 00:00:00 GMT+0000", + "msg": "assert response body start" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/object", + "params": { + "timestamp": "2016-10-10" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "6ecae5c7-b9b4-450d-865c-10aea2f6384c", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AWJZnlAAItW8H8a4UMGox8Iz7cv3TM5Zq.YRYNuDnd6fkHDDvlbilW9q4AkvSPwE8SsBs2JRC52HU" + } + }, + "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.date", + "assert": "equals", + "expect": 10, + "msg": "assert response body date" + }, + { + "check": "body.hours", + "assert": "equals", + "expect": 0, + "msg": "assert response body hours" + }, + { + "check": "body.milliseconds", + "assert": "equals", + "expect": 0, + "msg": "assert response body milliseconds" + }, + { + "check": "body.minutes", + "assert": "equals", + "expect": 0, + "msg": "assert response body minutes" + }, + { + "check": "body.months", + "assert": "equals", + "expect": 9, + "msg": "assert response body months" + }, + { + "check": "body.seconds", + "assert": "equals", + "expect": 0, + "msg": "assert response body seconds" + }, + { + "check": "body.years", + "assert": "equals", + "expect": 2016, + "msg": "assert response body years" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/before", + "params": { + "target": "2017-10-10", + "timestamp": "2016-10-10" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "faaa8cb6-13c5-4d0c-a7d2-133520637dde", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AJSsXggdxTpnvv6WVFqDrJ8Sjeuu77nE4.IcUuska8iBP1lkpKISqwIPOaqy5qLB%2F2o8v2Txs%2F5f8" + } + }, + "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.before", + "assert": "equals", + "expect": true, + "msg": "assert response body before" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/after", + "params": { + "target": "2017-10-10", + "timestamp": "2016-10-10" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "28c6c8f1-bb76-4fce-986c-adc2fd5df80d", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AQ9JCfRzQhaoMt6eD7gx_qk3JQ8CWnAxO.g3tHBGmTN8Vc1mqWWnSqGV1VOQdmKk8HG3z29e%2FBzhA" + } + }, + "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.after", + "assert": "equals", + "expect": false, + "msg": "assert response body after" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/between", + "params": { + "end": "2019-10-10", + "start": "2017-10-10", + "timestamp": "2016-10-10" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "32aaca4e-02a8-4559-9368-5705a1a65e19", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI" + } + }, + "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.between", + "assert": "equals", + "expect": false, + "msg": "assert response body between" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/time/leap", + "params": { + "timestamp": "2016-10-10" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "ff77428a-b157-463a-91e0-e5126d99d6c0", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI" + } + }, + "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.leap", + "assert": "equals", + "expect": true, + "msg": "assert response body leap" + } + ] + }, + { + "name": "", + "request": { + "method": "GET", + "url": "https://postman-echo.com/digest-auth", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Host": "postman-echo.com", + "Postman-Token": "8f6b453b-580c-44bc-8f9f-b2baa64ab530", + "User-Agent": "PostmanRuntime/7.28.4" + }, + "cookies": { + "sails.sid": "s%3AhLPrbCV0ByxRorQusdRky8bws0S2qQjf.V4SIDOu%2BdIgGVSCA5qvRYwhi3xR%2Bd0R9gL9RDUPdpI4" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 401, + "msg": "assert response status code" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/hrp/postman-echo.yaml b/examples/hrp/postman-echo.yaml new file mode 100644 index 00000000..32780813 --- /dev/null +++ b/examples/hrp/postman-echo.yaml @@ -0,0 +1,1101 @@ +config: + name: testcase description +teststeps: + - name: "" + request: + method: GET + url: https://postman-echo.com/get + params: + foo1: bar1 + foo2: bar2 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: ea19464c-ddd4-4724-abe9-5e2b254c2723 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3ASAXM8INphoz4_-5nCeQNBtrlsWuHs5Mt.83PsbOXUZUoPolzR2vpghXLUghDPLyA3NSrVKI8A8ws + 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=bar1&foo2=bar2 + msg: assert response body url + - name: "" + request: + method: POST + url: https://postman-echo.com/post + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Content-Length: "58" + Content-Type: text/plain + Host: postman-echo.com + Postman-Token: 40756814-a974-4fcc-98d2-1f2aec73c295 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3Ack89N2nb1AxU-T-nxvJrvOS1KvUXbiU2.3nAhh%2FjA%2F%2FNvHtWI8NApXa1QWV3hDD6LBsfUwpIdYQc + body: This is expected to be sent back as part of response body. + 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: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - 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: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Content-Length: "19" + Content-Type: application/x-www-form-urlencoded + Host: postman-echo.com + Postman-Token: 93843e50-2fe8-422d-b900-91095f9f0cdb + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A4bF7QNsgYKOBRnxJEclo-wiPIm6YxzFY.zmgnSBoVtZ3C40cBCJPsFS6KXTPoQBlKdS2FIdoxFaA + body: foo1=bar1&foo2=bar2 + 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 + - name: "" + request: + method: PUT + url: https://postman-echo.com/put + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Content-Length: "58" + Content-Type: text/plain + Host: postman-echo.com + Postman-Token: 5d357b2b-0f10-4ded-bc9a-299ebef7a2d5 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A7Kp8q3TlXZgZpLiLQNE4OGvpaqJwWmWX.SkW6gD2iyLO%2FFZYMAbg0bTsfuHwnEBezprz6nbykPWg + body: This is expected to be sent back as part of response body. + 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: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - check: body.url + assert: equals + expect: https://postman-echo.com/put + msg: assert response body url + - name: "" + request: + method: PATCH + url: https://postman-echo.com/patch + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Content-Length: "58" + Content-Type: text/plain + Host: postman-echo.com + Postman-Token: 27a30a79-5d88-43c0-8c83-fce5bb585729 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3ArMIVJXM1u78IGSzps0LYNjimloLEMdqk.6bzxgShLW4DTNlqRdZREK7OUV1kqu2kMHtEVxR9Xlyg + body: This is expected to be sent back as part of response body. + 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: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - check: body.url + assert: equals + expect: https://postman-echo.com/patch + msg: assert response body url + - name: "" + request: + method: DELETE + url: https://postman-echo.com/delete + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Content-Length: "58" + Content-Type: text/plain + Host: postman-echo.com + Postman-Token: b11f7819-4c39-41b3-9d06-696b38c3e515 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AlTv3pBzULeMHqjWpJWW-rwLZYYdqzSyW.J5YSZCf1unKehq5zNyuee%2B2xYkqoK%2BcTPTr3RzHYtYM + body: This is expected to be sent back as part of response body. + 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: This is expected to be sent back as part of response body. + msg: assert response body data + - check: body.json + assert: equals + expect: null + msg: assert response body json + - check: body.url + assert: equals + expect: https://postman-echo.com/delete + msg: assert response body url + - name: "" + request: + method: GET + url: https://postman-echo.com/headers + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 1a4e2039-d29b-4ed7-89e9-584b354246be + User-Agent: PostmanRuntime/7.28.4 + my-sample-header: Lorem ipsum dolor sit amet + cookies: + sails.sid: s%3A6Sj7Mduyb72fC-X0OQbDmFqp77bVEgt8.b5X8H%2BtACzKfkUlH%2FBtSYH%2FdSQ5fHynzHjK8gE3s%2FpI + 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 + - name: "" + request: + method: GET + url: https://postman-echo.com/response-headers + params: + foo1: bar1 + foo2: bar2 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: b00d3c25-a84b-4152-bcf8-4c573c06024b + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AvvP5l4Bk7WCLBU9LNXalNk4w4x3Q_2Zi.JiGgykR8RlAGIdRWv%2FdCmCL0Tbmwyni9KkXXgnzn59s + 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.foo1 + assert: equals + expect: bar1 + msg: assert response body foo1 + - check: body.foo2 + assert: equals + expect: bar2 + msg: assert response body foo2 + - name: "" + request: + method: GET + url: https://postman-echo.com/basic-auth + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Authorization: Basic cG9zdG1hbjpwYXNzd29yZA== + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: d9f810a2-292d-41c4-95e1-ec9f9ae778d6 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3APA71Iib2-7KqjRMajldmUsDqOqmRDB6-.zpTeobSmlq81Z7R%2FyL7q3o8%2FAP0tfOOZSPQdBlirJ6g + 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.authenticated + assert: equals + expect: true + msg: assert response body authenticated + - name: "" + request: + method: GET + url: https://postman-echo.com/digest-auth + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Authorization: Digest username="postman", realm="Users", nonce="W7kT5VowsR0pcTfL9fTwZKv2tRdEiG6c", uri="/digest-auth", algorithm="MD5", response="bab1b1e6534f84b43e9deb17bca9371b" + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 42e8340a-852b-4c7a-ab7d-d0b027f044ca + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AT2IbNG9nLojvklvDr1mo2cCftGUgcAgU.f1XqnM5ebKiLtIs3CKYYvBo7j5iHwiP9EuG9i91RR%2FU + 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.authenticated + assert: equals + expect: true + msg: assert response body authenticated + - name: "" + request: + method: GET + url: https://postman-echo.com/auth/hawk + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Authorization: Hawk id="dh37fgj492je", ts="1634367906", nonce="RZKGNz", mac="EASK1an/9fmDhFJcqH8XE4pTuUaSJisuQVM+NCOjNlM=" + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 46645864-583c-446b-9d36-9610fb114d99 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AWyHRwAoLc64u8sF_LqU0BUYAieEguHiH.gb%2BNYX72g6n5lHjLdl5K1hsKmLHYJUwoOwKkDWVl7qY + 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.message + assert: equals + expect: Hawk Authentication Successful + msg: assert response body message + - name: "" + request: + method: GET + url: https://postman-echo.com/oauth1 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Authorization: OAuth oauth_consumer_key="RKCGzna7bv9YD57c",oauth_signature_method="HMAC-SHA1",oauth_timestamp="1634367907",oauth_nonce="pAoTV0k5VZa",oauth_signature="ZTkfsaUA1B2s7kyl3HaFm1zFow4%3D" + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 3d9db9bb-5bcf-425e-b0e4-a958c07d7969 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AZQRuQaIb28umtrzP-HOj4fSqeag88Pvj.KVLylhlYJ3JKMHUS0UVeLCT6qRcBgQl%2BM14UxI7EgQs + 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.message + assert: equals + expect: OAuth-1.0a signature verification was successful + msg: assert response body message + - check: body.status + assert: equals + expect: pass + msg: assert response body status + - name: "" + request: + method: GET + url: https://postman-echo.com/cookies/set + params: + foo1: bar1 + foo2: bar2 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: ff927796-58d3-4f43-8701-8411747c4313 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AsdmvN2_ZNE0YlwQY5GxY04ptWTOYR5NU.kkH0dnWlEMsblzPMurLX8nsQRRbRqLqteIhA0621onY + 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 + - name: "" + request: + method: GET + url: https://postman-echo.com/cookies + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: ff927796-58d3-4f43-8701-8411747c4313 + Referer: https://postman-echo.com/cookies/set?foo1=bar1&foo2=bar2 + User-Agent: PostmanRuntime/7.28.4 + cookies: + foo1: bar1 + foo2: bar2 + sails.sid: s%3AlVpTnkb0ofz6HC7QJMVtiRexW3u_onsT.rmsoerMcOQOu7KYPU80x%2FBiieqBESMNj%2FxuCvbbw%2BsQ + 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 + - name: "" + request: + method: GET + url: https://postman-echo.com/cookies + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 2dbc6d22-1713-4b96-a1a2-3358b1a1deaa + User-Agent: PostmanRuntime/7.28.4 + cookies: + foo1: bar1 + foo2: bar2 + sails.sid: s%3Avz13GzkqWaYvFuB3I35udi2vLsikZZgi.YgVWfqmyjPpEduyCIZDFGyDSPYY8%2FFM7HePC5Ok0hQM + 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 + - name: "" + request: + method: GET + url: https://postman-echo.com/cookies/delete + params: + foo1: "" + foo2: "" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 8837dd89-9db7-4f06-9187-e7a85a99b945 + User-Agent: PostmanRuntime/7.28.4 + cookies: + foo1: bar1 + foo2: bar2 + sails.sid: s%3AQ8MT5sT-2LAO0Rk7bNLLR18UQWgaJMsg.eOEyhDjqWGwn2rdqWeGLstPmrn5H1OUZGlDLuI%2F1Nng + 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 + - name: "" + request: + method: GET + url: https://postman-echo.com/cookies + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 8837dd89-9db7-4f06-9187-e7a85a99b945 + Referer: https://postman-echo.com/cookies/delete?foo1&foo2 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A1atMUPWbEEDiMqdbTqbddbqiFujSi1l2.6n40eqlOkTsKoB6K7xT98PrfQweiPlTjJTfZl%2FpAEsU + 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 + - name: "" + request: + method: GET + url: https://postman-echo.com/status/200 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 5f4c6d97-d476-407e-bbf9-532480f618d8 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A5p9FN9UVGZ9XJl6I9FXiz0AwIQRRU1ka.RFuMLR9arGQaLkM1gbvuPosvzPxsREHGEjjiVF4TXnQ + 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.status + assert: equals + expect: 200 + msg: assert response body status + - name: "" + request: + method: GET + url: https://postman-echo.com/stream/5 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 24ca01aa-6c3f-4a78-a437-33dfa8dadd0f + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AFD7Hy01JAAenWz9SoQQhJxH4Qxel9sbP.%2Ba5JmTwqOpkc%2FAOLOzzsfStpK2MTfZCYXiCoA39Zt7w + validate: + - check: status_code + assert: equals + expect: 200 + msg: assert response status code + - name: "" + request: + method: GET + url: https://postman-echo.com/delay/2 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: d2ade32f-4bb8-4e6d-90d3-5fa7560def12 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AqSePO9_VmCbBbVvsCMYMHm3lShKdFNWU.RFuwKJdlZHVyB0gF1x2Yt78v5jKbese6f8HNPIjI5AY + 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.delay + assert: equals + expect: "2" + msg: assert response body delay + - name: "" + request: + method: GET + url: https://postman-echo.com/encoding/utf8 + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: bd39f8e4-8072-4ec3-b498-3aaacb621544 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AXrCX-GaGzqizPQY2AdLTLNPO_cFgVsGD.BwOoj2gClsAzDrsP0%2FObypcumuYCfV%2F4vHCrKIWdTAQ + validate: + - check: status_code + assert: equals + expect: 200 + msg: assert response status code + - check: headers."Content-Type" + assert: equals + expect: text/html; charset=utf-8 + msg: assert response header Content-Type + - name: "" + request: + method: GET + url: https://postman-echo.com/gzip + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: ef40db18-75f9-4d0c-9fe8-94274a0a589e + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU + 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.gzipped + assert: equals + expect: true + msg: assert response body gzipped + - check: body.method + assert: equals + expect: GET + msg: assert response body method + - name: "" + request: + method: GET + url: https://postman-echo.com/deflate + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 06b47e94-9131-4ab7-8d0e-d0990f1a1144 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AdknETdvYiCwRbtxpWR58ZhmohmZJOqdI.SA8%2FR072CZkldOTuVv7TYyKpzEQWpkt%2F2YTTTBFn%2BzU + 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.deflated + assert: equals + expect: true + msg: assert response body deflated + - check: body.method + assert: equals + expect: GET + msg: assert response body method + - name: "" + request: + method: GET + url: https://postman-echo.com/ip + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 246c423e-9285-4fad-b471-434bf4bf3369 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A_sZ_Nn5QQ0b2Swfp9tMHX9CWKJb9X3is.fa%2FQ9D9WhuFBgpatC2Yo33cPynch4YqbG%2Fw9iB92Jxo + 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.ip + assert: equals + expect: 122.14.229.79 + msg: assert response body ip + - name: "" + request: + method: GET + url: https://postman-echo.com/time/now + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: e1107fa9-80cb-4e69-b3dd-6fd0c92832b1 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs + validate: + - check: status_code + assert: equals + expect: 200 + msg: assert response status code + - check: headers."Content-Type" + assert: equals + expect: text/html; charset=utf-8 + msg: assert response header Content-Type + - name: "" + request: + method: GET + url: https://postman-echo.com/time/valid + params: + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 05eb8403-8a83-4bde-bdd4-67952910c00f + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AFqdFnM7dGE1ds2DZfijQergoGKJKdivs.TZy6jaQuf3wKK7VHSuQRNwDrZuuvCx3pGhhj7lKouQs + 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.valid + assert: equals + expect: true + msg: assert response body valid + - name: "" + request: + method: GET + url: https://postman-echo.com/time/format + params: + format: mm + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 7bab6bdc-6fe5-4eb8-aff0-3cfa08e5a823 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3Ai_9yOOqBlD9Nq0-5kptXL_qLhgITKpaZ.HU5sTJC0jVIzJvykONaDFYTiMZrZpQgdiwMInhSADss + 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.format + assert: equals + expect: "20" + msg: assert response body format + - name: "" + request: + method: GET + url: https://postman-echo.com/time/unit + params: + timestamp: "2016-10-10" + unit: day + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 8dbb7595-3ff0-47cd-8883-4c1f24a840ef + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8 + 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.unit + assert: equals + expect: 1 + msg: assert response body unit + - name: "" + request: + method: GET + url: https://postman-echo.com/time/add + params: + timestamp: "2016-10-10" + years: "100" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 12c5137f-ee8e-48c2-b1b7-99c85f0667e4 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AlSI63UO-j2SWcK0YQfFAScLu2YKvhtlr.0wPoZkmPHUiNtTVy55Bdt9ulnQxk%2FahmG6a7%2BE6gtg8 + 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.sum + assert: equals + expect: Sat Oct 10 2116 00:00:00 GMT+0000 + msg: assert response body sum + - name: "" + request: + method: GET + url: https://postman-echo.com/time/subtract + params: + timestamp: "2016-10-10" + years: "50" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: d903ee32-4361-44a4-af56-819e7fa10cc4 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A5OS8kEURZ8ZYZzfO7we0KvxaGI1AdMRZ.L6C2S4%2B6rTQd5qdQufDhV9rDv9CJgENLudOAk9h0Yow + 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.difference + assert: equals + expect: Mon Oct 10 1966 00:00:00 GMT+0000 + msg: assert response body difference + - name: "" + request: + method: GET + url: https://postman-echo.com/time/start + params: + timestamp: "2016-10-10" + unit: month + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 2d666d32-2815-45be-ae8d-266eea519043 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3A2PKCLJCVRo_5V_uagkV5b3Kn9dV0eQUm.Dp5OFZ%2FCtOcDKqB8y8yywFHO6LbN9oe10o4DQ%2FnoKRk + 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.start + assert: equals + expect: Sat Oct 01 2016 00:00:00 GMT+0000 + msg: assert response body start + - name: "" + request: + method: GET + url: https://postman-echo.com/time/object + params: + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 6ecae5c7-b9b4-450d-865c-10aea2f6384c + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AWJZnlAAItW8H8a4UMGox8Iz7cv3TM5Zq.YRYNuDnd6fkHDDvlbilW9q4AkvSPwE8SsBs2JRC52HU + 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.date + assert: equals + expect: 10 + msg: assert response body date + - check: body.hours + assert: equals + expect: 0 + msg: assert response body hours + - check: body.milliseconds + assert: equals + expect: 0 + msg: assert response body milliseconds + - check: body.minutes + assert: equals + expect: 0 + msg: assert response body minutes + - check: body.months + assert: equals + expect: 9 + msg: assert response body months + - check: body.seconds + assert: equals + expect: 0 + msg: assert response body seconds + - check: body.years + assert: equals + expect: 2016 + msg: assert response body years + - name: "" + request: + method: GET + url: https://postman-echo.com/time/before + params: + target: "2017-10-10" + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: faaa8cb6-13c5-4d0c-a7d2-133520637dde + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AJSsXggdxTpnvv6WVFqDrJ8Sjeuu77nE4.IcUuska8iBP1lkpKISqwIPOaqy5qLB%2F2o8v2Txs%2F5f8 + 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.before + assert: equals + expect: true + msg: assert response body before + - name: "" + request: + method: GET + url: https://postman-echo.com/time/after + params: + target: "2017-10-10" + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 28c6c8f1-bb76-4fce-986c-adc2fd5df80d + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AQ9JCfRzQhaoMt6eD7gx_qk3JQ8CWnAxO.g3tHBGmTN8Vc1mqWWnSqGV1VOQdmKk8HG3z29e%2FBzhA + 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.after + assert: equals + expect: false + msg: assert response body after + - name: "" + request: + method: GET + url: https://postman-echo.com/time/between + params: + end: "2019-10-10" + start: "2017-10-10" + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 32aaca4e-02a8-4559-9368-5705a1a65e19 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI + 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.between + assert: equals + expect: false + msg: assert response body between + - name: "" + request: + method: GET + url: https://postman-echo.com/time/leap + params: + timestamp: "2016-10-10" + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: ff77428a-b157-463a-91e0-e5126d99d6c0 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AYE-1ygWzH5aScrDeYC7-Q8-dC1A5zkJv.XyirbigQ0duqX6jD9om1q%2FS%2FqkhbFl43yu7HHYciXkI + 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.leap + assert: equals + expect: true + msg: assert response body leap + - name: "" + request: + method: GET + url: https://postman-echo.com/digest-auth + headers: + Accept: '*/*' + Accept-Encoding: gzip, deflate, br + Cache-Control: no-cache + Connection: keep-alive + Host: postman-echo.com + Postman-Token: 8f6b453b-580c-44bc-8f9f-b2baa64ab530 + User-Agent: PostmanRuntime/7.28.4 + cookies: + sails.sid: s%3AhLPrbCV0ByxRorQusdRky8bws0S2qQjf.V4SIDOu%2BdIgGVSCA5qvRYwhi3xR%2Bd0R9gL9RDUPdpI4 + validate: + - check: status_code + assert: equals + expect: 401 + msg: assert response status code diff --git a/examples/hrp/ref_api_test.json b/examples/hrp/ref_api_test.json new file mode 100644 index 00000000..8e69392f --- /dev/null +++ b/examples/hrp/ref_api_test.json @@ -0,0 +1,78 @@ +{ + "config": { + "name": "api test demo", + "variables": { + "user_agent": "iOS/10.3", + "device_sn": "TESTCASE_SETUP_XXX", + "os_platform": "ios", + "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" + } + ], + "verify": false, + "export": [ + "session_token" + ] + }, + "teststeps": [ + { + "name": "test api /get", + "api": "api/get.json", + "variables": { + "user_agent": "iOS/10.4", + "device_sn": "$device_sn", + "os_platform": "ios", + "app_version": "2.8.7" + }, + "extract": { + "session_token": "body.headers.\"postman-token\"" + } + }, + { + "name": "test api /post", + "api": "api/post.json", + "variables": { + "user_agent": "iOS/10.5", + "device_sn": "$device_sn", + "os_platform": "ios", + "app_version": "2.8.9" + }, + "validate": [ + { + "eq": [ + "status_code", + 200 + ] + }, + { + "eq": [ + "body.headers.postman-token", + "ea19464c-ddd4-4724-abe9-5e2b254c2723" + ] + } + ] + }, + { + "name": "test api /put", + "api": "api/put.json", + "variables": { + "user_agent": "iOS/10.6", + "device_sn": "$device_sn", + "os_platform": "ios", + "app_version": "2.8.10" + }, + "extract": { + "session_token": "body.headers.\"postman-token\"" + } + } + ] +} \ No newline at end of file diff --git a/examples/hrp/ref_api_test.yaml b/examples/hrp/ref_api_test.yaml new file mode 100644 index 00000000..c920aae0 --- /dev/null +++ b/examples/hrp/ref_api_test.yaml @@ -0,0 +1,47 @@ +config: + name: 'api test demo' + variables: + user_agent: iOS/10.3 + device_sn: TESTCASE_SETUP_XXX + os_platform: ios + 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 + verify: false + export: + - session_token +teststeps: + - name: 'test api /get' + api: api/get.json + variables: + user_agent: iOS/10.4 + device_sn: $device_sn + os_platform: ios + app_version: 2.8.7 + extract: + session_token: 'body.headers."postman-token"' + - name: 'test api /post' + api: api/post.json + variables: + user_agent: iOS/10.5 + device_sn: $device_sn + os_platform: ios + app_version: 2.8.9 + validate: + - { eq: [ status_code, 200 ] } + - { eq: [ body.headers.postman-token, ea19464c-ddd4-4724-abe9-5e2b254c2723 ] } + - name: 'test api /put' + api: api/put.json + variables: + user_agent: iOS/10.6 + device_sn: $device_sn + os_platform: ios + app_version: 2.8.10 + extract: + session_token: 'body.headers."postman-token"' diff --git a/examples/hrp/ref_testcase_test.json b/examples/hrp/ref_testcase_test.json new file mode 100644 index 00000000..39bc01d6 --- /dev/null +++ b/examples/hrp/ref_testcase_test.json @@ -0,0 +1,18 @@ +{ + "config": { + "name": "reference testcase test", + "base_url": "https://postman-echo.com", + "variables": { + "os_platform": "ios" + } + }, + "teststeps": [ + { + "name": "run demo_httprunner.json", + "testcase": "demo_httprunner.json", + "variables": { + "os_platform": "$os_platform" + } + } + ] +} diff --git a/examples/hrp/ref_testcase_test.yaml b/examples/hrp/ref_testcase_test.yaml new file mode 100644 index 00000000..f3811e1f --- /dev/null +++ b/examples/hrp/ref_testcase_test.yaml @@ -0,0 +1,11 @@ +config: + name: "reference testcase test" + base_url: "https://postman-echo.com" + variables: + os_platform: 'ios' + +teststeps: + - name: run demo_httprunner.yaml + testcase: demo_httprunner.yaml + variables: + os_platform: $os_platform \ No newline at end of file diff --git a/examples/hrp/rendezvous_test.go b/examples/hrp/rendezvous_test.go new file mode 100644 index 00000000..d8967f31 --- /dev/null +++ b/examples/hrp/rendezvous_test.go @@ -0,0 +1,56 @@ +package examples + +import ( + "testing" + + "github.com/httprunner/httprunner/hrp" +) + +const rendezvousTestJSONPath = "rendezvous_test.json" + +var rendezvousTestcase = &hrp.TestCase{ + Config: hrp.NewConfig("run request with functions"). + SetBaseURL("https://postman-echo.com"). + WithVariables(map[string]interface{}{ + "n": 5, + "a": 12.3, + "b": 3.45, + }), + TestSteps: []hrp.IStep{ + hrp.NewStep("waiting for all users in the beginning"). + Rendezvous("rendezvous0"), + hrp.NewStep("rendezvous before get"). + Rendezvous("rendezvous1"). + WithUserNumber(50). + WithTimeout(3000), + hrp.NewStep("get with params"). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "foo1", "foo2": "foo2"}). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). + Extract(). + WithJmesPath("body.args.foo1", "varFoo1"). + Validate(). + AssertEqual("status_code", 200, "check status code"), + hrp.NewStep("rendezvous before post"). + Rendezvous("rendezvous2"). + WithUserNumber(20). + WithTimeout(2000), + hrp.NewStep("post json data with functions"). + POST("/post"). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). + WithBody(map[string]interface{}{"foo1": "foo1", "foo2": "foo2"}). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.json.foo1", 4, "check args foo1"). + AssertEqual("body.json.foo2", "foo2", "check args foo2"), + hrp.NewStep("waiting for all users in the end"). + Rendezvous("rendezvous3"), + }, +} + +func TestRendezvous(t *testing.T) { + err := hrp.NewRunner(t).Run(rendezvousTestcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} diff --git a/examples/hrp/rendezvous_test.json b/examples/hrp/rendezvous_test.json new file mode 100644 index 00000000..278c4b80 --- /dev/null +++ b/examples/hrp/rendezvous_test.json @@ -0,0 +1,106 @@ +{ + "config": { + "name": "run request with functions", + "base_url": "https://postman-echo.com", + "variables": { + "a": 12.3, + "b": 3.45, + "n": 5 + }, + "parameters_setting": { + "strategy": "Sequential", + "parameterIterator": [ + {} + ] + } + }, + "teststeps": [ + { + "name": "waiting for all users in the beginning", + "rendezvous": { + "name": "rendezvous0" + } + }, + { + "name": "rendezvous before get", + "rendezvous": { + "name": "rendezvous1", + "number": 50, + "timeout": 3000 + } + }, + { + "name": "get with params", + "request": { + "method": "GET", + "url": "/get", + "params": { + "foo1": "foo1", + "foo2": "foo2" + }, + "headers": { + "User-Agent": "HttpRunnerPlus" + } + }, + "extract": { + "varFoo1": "body.args.foo1" + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "check status code" + } + ] + }, + { + "name": "rendezvous before post", + "rendezvous": { + "name": "rendezvous2", + "number": 20, + "timeout": 2000 + } + }, + { + "name": "post json data with functions", + "request": { + "method": "POST", + "url": "/post", + "headers": { + "User-Agent": "HttpRunnerPlus" + }, + "body": { + "foo1": "foo1", + "foo2": "foo2" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "check status code" + }, + { + "check": "body.json.foo1", + "assert": "length_equals", + "expect": 4, + "msg": "check args foo1" + }, + { + "check": "body.json.foo2", + "assert": "equals", + "expect": "foo2", + "msg": "check args foo2" + } + ] + }, + { + "name": "waiting for all users in the end", + "rendezvous": { + "name": "rendezvous3" + } + } + ] +} \ No newline at end of file diff --git a/examples/hrp/request_test.go b/examples/hrp/request_test.go new file mode 100644 index 00000000..75a76762 --- /dev/null +++ b/examples/hrp/request_test.go @@ -0,0 +1,74 @@ +package examples + +import ( + "testing" + + "github.com/httprunner/httprunner/hrp" +) + +func TestCaseBasicRequest(t *testing.T) { + testcase := &hrp.TestCase{ + Config: hrp.NewConfig("request methods testcase in hardcode"). + SetBaseURL("https://postman-echo.com"). + SetVerifySSL(false), + TestSteps: []hrp.IStep{ + hrp.NewStep("get with params"). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "bar1", "foo2": "bar2"}). + WithHeaders(map[string]string{ + "User-Agent": "HttpRunnerPlus", + }). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("headers.\"Content-Type\"", "application/json; charset=utf-8", "check header Content-Type"). + AssertEqual("body.args.foo1", "bar1", "check args foo1"). + AssertEqual("body.args.foo2", "bar2", "check args foo2"), + hrp.NewStep("post raw text"). + POST("/post"). + WithHeaders(map[string]string{ + "User-Agent": "HttpRunnerPlus", + "Content-Type": "text/plain", + }). + WithBody("This is expected to be sent back as part of response body."). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("body.data", "This is expected to be sent back as part of response body.", "check data"), + hrp.NewStep("post form data"). + POST("/post"). + WithHeaders(map[string]string{ + "User-Agent": "HttpRunnerPlus", + "Content-Type": "application/x-www-form-urlencoded", + }). + WithBody(map[string]interface{}{"foo1": "bar1", "foo2": "bar2"}). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("body.form.foo1", "bar1", "check form foo1"). + AssertEqual("body.form.foo2", "bar2", "check form foo2"), + hrp.NewStep("post json data"). + POST("/post"). + WithHeaders(map[string]string{ + "User-Agent": "HttpRunnerPlus", + }). + WithBody(map[string]interface{}{"foo1": "bar1", "foo2": "bar2"}). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("body.json.foo1", "bar1", "check json foo1"). + AssertEqual("body.json.foo2", "bar2", "check json foo2"), + hrp.NewStep("put request"). + PUT("/put"). + WithHeaders(map[string]string{ + "User-Agent": "HttpRunnerPlus", + "Content-Type": "text/plain", + }). + WithBody("This is expected to be sent back as part of response body."). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("body.data", "This is expected to be sent back as part of response body.", "check data"), + }, + } + + err := hrp.NewRunner(t).Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} diff --git a/examples/hrp/think_time_test.json b/examples/hrp/think_time_test.json new file mode 100644 index 00000000..fddb4545 --- /dev/null +++ b/examples/hrp/think_time_test.json @@ -0,0 +1,63 @@ +{ + "config": { + "name": "think time test demo", + "variables": { + "app_version": "v1", + "user_agent": "iOS/10.3" + }, + "base_url": "https://postman-echo.com", + "think_time": { + "strategy": "random_percentage", + "setting": { + "min_percentage": 1, + "max_percentage": 1.5 + }, + "limit": 4 + }, + "verify": false + }, + "teststeps": [ + { + "name": "get with params", + "request": { + "method": "GET", + "url": "/get", + "headers": { + "User-Agent": "$user_agent,$app_version" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "check status code" + } + ] + }, + { + "name": "think time 1", + "think_time": { + "time": 3 + } + }, + { + "name": "post with params", + "request": { + "method": "POST", + "url": "/post", + "headers": { + "User-Agent": "$user_agent,$app_version" + } + }, + "validate": [ + { + "check": "status_code", + "assert": "equals", + "expect": 200, + "msg": "check status code" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/hrp/think_time_test.yaml b/examples/hrp/think_time_test.yaml new file mode 100644 index 00000000..9f2f5129 --- /dev/null +++ b/examples/hrp/think_time_test.yaml @@ -0,0 +1,40 @@ +config: + name: "think time test demo" + variables: + app_version: v1 + user_agent: iOS/10.3 + base_url: "https://postman-echo.com" + think_time: + strategy: random_percentage + setting: + min_percentage: 1.0 + max_percentage: 1.5 + limit: 4 + verify: False + +teststeps: + - name: get with params + request: + method: GET + url: /get + headers: + User-Agent: $user_agent,$app_version + validate: + - check: status_code + assert: equals + expect: 200 + msg: check status code + - name: think time 1 + think_time: + time: 3 + - name: post with params + request: + method: POST + url: /post + headers: + User-Agent: $user_agent,$app_version + validate: + - check: status_code + assert: equals + expect: 200 + msg: check status code \ No newline at end of file diff --git a/examples/hrp/validate_test.go b/examples/hrp/validate_test.go new file mode 100644 index 00000000..1a85d410 --- /dev/null +++ b/examples/hrp/validate_test.go @@ -0,0 +1,55 @@ +package examples + +import ( + "testing" + + "github.com/httprunner/httprunner/hrp" +) + +func TestCaseValidateStep(t *testing.T) { + testcase := &hrp.TestCase{ + Config: hrp.NewConfig("run request with validation"). + SetBaseURL("https://postman-echo.com"). + SetVerifySSL(false), + TestSteps: []hrp.IStep{ + hrp.NewStep("get with params"). + WithVariables(map[string]interface{}{ + "var1": "bar1", + "agent": "HttpRunnerPlus", + "expectedStatusCode": 200, + }). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "$var1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "$agent"}). + Extract(). + WithJmesPath("body.args.foo1", "varFoo1"). + Validate(). + AssertEqual("status_code", "$expectedStatusCode", "check status code"). // assert status code + AssertEqual("headers.\"Content-Type\"", "application/json; charset=utf-8", "check header Content-Type"). // assert response header, with double quotes + AssertEqual("body.args.foo1", "bar1", "check args foo1"). // assert response json body with jmespath + AssertEqual("body.args.foo2", "bar2", "check args foo2"). + AssertEqual("body.headers.\"user-agent\"", "HttpRunnerPlus", "check header user agent"), + hrp.NewStep("get with params"). + WithVariables(map[string]interface{}{ + "var1": "bar1", + "agent": "HttpRunnerPlus", + }). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "$var1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "$agent"}). + Extract(). + WithJmesPath("status_code", "statusCode"). + WithJmesPath("headers.\"Content-Type\"", "contentType"). + Validate(). + AssertEqual("$statusCode", 200, "check status code"). // assert with extracted variable from current step + AssertEqual("$contentType", "application/json; charset=utf-8", "check header Content-Type"). // assert with extracted variable from current step + AssertEqual("$varFoo1", "bar1", "check args foo1"). // assert with extracted variable from previous step + AssertEqual("body.args.foo2", "bar2", "check args foo2"), // assert response json body with jmespath + }, + } + + err := hrp.NewRunner(t).Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} diff --git a/examples/hrp/variables_test.go b/examples/hrp/variables_test.go new file mode 100644 index 00000000..d2a9f834 --- /dev/null +++ b/examples/hrp/variables_test.go @@ -0,0 +1,144 @@ +package examples + +import ( + "testing" + + "github.com/httprunner/httprunner/hrp" +) + +func TestCaseConfigVariables(t *testing.T) { + testcase := &hrp.TestCase{ + Config: hrp.NewConfig("run request with variables"). + SetBaseURL("https://postman-echo.com"). + WithVariables(map[string]interface{}{ + "var1": "bar1", + "agent": "HttpRunnerPlus", + "expectedStatusCode": 200, + }).SetVerifySSL(false), + TestSteps: []hrp.IStep{ + hrp.NewStep("get with params"). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "$var1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "$agent"}). + Validate(). + AssertEqual("status_code", "$expectedStatusCode", "check status code"). + AssertEqual("headers.\"Content-Type\"", "application/json; charset=utf-8", "check header Content-Type"). + AssertEqual("body.args.foo1", "bar1", "check args foo1"). + AssertEqual("body.args.foo2", "bar2", "check args foo2"). + AssertEqual("body.headers.\"user-agent\"", "HttpRunnerPlus", "check header user agent"), + }, + } + + err := hrp.NewRunner(t).Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} + +func TestCaseStepVariables(t *testing.T) { + testcase := &hrp.TestCase{ + Config: hrp.NewConfig("run request with variables"). + SetBaseURL("https://postman-echo.com"). + SetVerifySSL(false), + TestSteps: []hrp.IStep{ + hrp.NewStep("get with params"). + WithVariables(map[string]interface{}{ + "var1": "bar1", + "agent": "HttpRunnerPlus", + "expectedStatusCode": 200, + }). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "$var1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "$agent"}). + Validate(). + AssertEqual("status_code", "$expectedStatusCode", "check status code"). + AssertEqual("headers.\"Content-Type\"", "application/json; charset=utf-8", "check header Content-Type"). + AssertEqual("body.args.foo1", "bar1", "check args foo1"). + AssertEqual("body.args.foo2", "bar2", "check args foo2"). + AssertEqual("body.headers.\"user-agent\"", "HttpRunnerPlus", "check header user agent"), + }, + } + + err := hrp.NewRunner(t).Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} + +func TestCaseOverrideConfigVariables(t *testing.T) { + testcase := &hrp.TestCase{ + Config: hrp.NewConfig("run request with variables"). + SetBaseURL("https://postman-echo.com"). + WithVariables(map[string]interface{}{ + "var1": "bar0", + "agent": "HttpRunnerPlus", + "expectedStatusCode": 200, + }).SetVerifySSL(false), + TestSteps: []hrp.IStep{ + hrp.NewStep("get with params"). + WithVariables(map[string]interface{}{ + "var1": "bar1", // override config variable + "agent": "$agent", // reference config variable + // expectedStatusCode, inherit config variable + }). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "$var1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "$agent"}). + Validate(). + AssertEqual("status_code", "$expectedStatusCode", "check status code"). + AssertEqual("headers.\"Content-Type\"", "application/json; charset=utf-8", "check header Content-Type"). + AssertEqual("body.args.foo1", "bar1", "check args foo1"). + AssertEqual("body.args.foo2", "bar2", "check args foo2"). + AssertEqual("body.headers.\"user-agent\"", "HttpRunnerPlus", "check header user agent"), + }, + } + + err := hrp.NewRunner(t).Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} + +func TestCaseParseVariables(t *testing.T) { + testcase := &hrp.TestCase{ + Config: hrp.NewConfig("run request with functions"). + SetBaseURL("https://postman-echo.com"). + WithVariables(map[string]interface{}{ + "n": 5, + "a": 12.3, + "b": 3.45, + "varFoo1": "${gen_random_string($n)}", + "varFoo2": "${max($a, $b)}", // 12.3 + }).SetVerifySSL(false), + TestSteps: []hrp.IStep{ + hrp.NewStep("get with params"). + WithVariables(map[string]interface{}{ + "n": 3, + "b": 34.5, + "varFoo2": "${max($a, $b)}", // 34.5 + }). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "$varFoo1", "foo2": "$varFoo2"}). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). + Extract(). + WithJmesPath("body.args.foo1", "varFoo1"). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.args.foo1", 5, "check args foo1"). + AssertEqual("body.args.foo2", "34.5", "check args foo2"), // notice: request params value will be converted to string + hrp.NewStep("post json data with functions"). + POST("/post"). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). + WithBody(map[string]interface{}{"foo1": "${gen_random_string($n)}", "foo2": "${max($a, $b)}"}). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.json.foo1", 5, "check args foo1"). + AssertEqual("body.json.foo2", 12.3, "check args foo2"), + }, + } + + err := hrp.NewRunner(t).Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..6a4ec496 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module github.com/httprunner/httprunner + +go 1.16 + +require ( + github.com/andybalholm/brotli v1.0.4 + github.com/denisbrodbeck/machineid v1.0.1 + github.com/google/uuid v1.3.0 + github.com/httprunner/funplugin v0.4.0 + github.com/jinzhu/copier v0.3.2 + github.com/jmespath/go-jmespath v0.4.0 + github.com/json-iterator/go v1.1.12 + github.com/maja42/goval v1.2.1 + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/olekukonko/tablewriter v0.0.5 + github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.11.0 + github.com/rs/zerolog v1.26.1 + github.com/spf13/cobra v1.2.1 + github.com/stretchr/testify v1.7.0 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b +) + +// replace github.com/httprunner/funplugin => ../funplugin diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..9daae201 --- /dev/null +++ b/go.sum @@ -0,0 +1,737 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= +github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= +github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v1.1.0 h1:QsGcniKx5/LuX2eYoeL+Np3UKYPNaN7YKpTh29h8rbw= +github.com/hashicorp/go-hclog v1.1.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-plugin v1.4.3 h1:DXmvivbWD5qdiBts9TpBC7BYL1Aia5sxbRgQB+v6UZM= +github.com/hashicorp/go-plugin v1.4.3/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb h1:b5rjCoWHc7eqmAS4/qyk21ZsHyb6Mxv/jykxvNTkU4M= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/httprunner/funplugin v0.4.0 h1:jSptZ6Ki0Dh3uvpLDbmxE6kSqVv0FHaQnHs0Qt+6SS8= +github.com/httprunner/funplugin v0.4.0/go.mod h1:vPyeJIfbpGe0epZZtAV0wCn16gLY9+imSw/zfxq0Lcc= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE= +github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= +github.com/jinzhu/copier v0.3.2 h1:QdBOCbaouLDYaIPFfi1bKv5F5tPpeTwXe4sD0jqtz5w= +github.com/jinzhu/copier v0.3.2/go.mod h1:24xnZezI2Yqac9J61UC6/dG/k76ttpq0DdJI3QmUvro= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +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.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/maja42/goval v1.2.1 h1:fyEgzddqPgCZsKcFLk4C6SdCHyEaAHYvtZG4mGzQOHU= +github.com/maja42/goval v1.2.1/go.mod h1:42LU+BQXL/veE9jnTTUOSj38GRmOTSThYSXRVodI5J4= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/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= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ= +github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= +github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw= +github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= +go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 h1:ErU+UA6wxadoU8nWrsy5MZUVBs75K17zUCsUCIfrXCE= +google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= +google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/hrp/README.md b/hrp/README.md new file mode 100644 index 00000000..f27e5d1d --- /dev/null +++ b/hrp/README.md @@ -0,0 +1,316 @@ +# hrp (HttpRunner+) + +[![Go Reference](https://pkg.go.dev/badge/github.com/httprunner/hrp.svg)](https://pkg.go.dev/github.com/httprunner/hrp) +[![Github Actions](https://github.com/httprunner/hrp/actions/workflows/unittest.yml/badge.svg)](https://github.com/httprunner/hrp/actions) +[![codecov](https://codecov.io/gh/httprunner/hrp/branch/main/graph/badge.svg?token=HPCQWCD7KO)](https://codecov.io/gh/httprunner/hrp) +[![Go Report Card](https://goreportcard.com/badge/github.com/httprunner/hrp)](https://goreportcard.com/report/github.com/httprunner/hrp) +[![FOSSA Status](https://app.fossa.com/api/projects/custom%2B27856%2Fgithub.com%2Fhttprunner%2Fhrp.svg?type=shield)](https://app.fossa.com/reports/c2742455-c8ab-4b13-8fd7-4a35ba0b2840) + +`hrp` aims to be a one-stop solution for HTTP(S) testing, covering API testing, load testing and digital experience monitoring (DEM). + +See [CHANGELOG]. + +> HttpRunner [用户调研问卷][survey] 持续收集中,我们将基于用户反馈动态调整产品特性和需求优先级。 + +## Key Features + +![flow chart](docs/assets/flow.jpg) + +### API Testing + +- [x] Full support for HTTP(S)/1.1 requests. +- [ ] Support more protocols, HTTP/2, WebSocket, TCP, RPC etc. +- [x] Testcases can be described in multiple formats, `YAML`/`JSON`/`Golang`, and they are interchangeable. +- [x] Use Charles/Fiddler/Chrome/etc to record HTTP requests and generate testcases from exported [`HAR`][HAR]. +- [x] Supports `variables`/`extract`/`validate`/`hooks` mechanisms to create extremely complex test scenarios. +- [x] Data driven with `parameterize` mechanism, supporting sequential/random/unique strategies to select data. +- [ ] Built-in 100+ commonly used functions for ease, including md5sum, max/min, sleep, gen_random_string etc. +- [x] Create and call custom functions with `plugin` mechanism, support [hashicorp plugin] and [go plugin]. +- [x] Generate html reports with rich test results. +- [x] Using it as a `CLI tool` or a `library` are both supported. + +### Load Testing + +Base on the API testing testcases, you can run professional load testing without extra work. + +- [x] Inherit all powerful features of [`locust`][locust] and [`boomer`][boomer]. +- [x] Report performance metrics to [prometheus pushgateway][pushgateway]. +- [x] Use `transaction` to define a set of end-user actions that represent the real user activities. +- [x] Use `rendezvous` points to force Vusers to perform tasks concurrently during test execution. +- [x] Load testing with specified concurrent users or constant RPS, also supports spawn rate. +- [ ] Support mixed-scenario testing with custom weight. +- [ ] Simulate browser's HTTP parallel connections. +- [ ] IP spoofing. +- [ ] Run in distributed mode to generate unlimited RPS. + +### Digital Experience Monitoring (DEM) + +You can also monitor online services for digital experience assessments. + +- [ ] HTTP(S) latency statistics including DNSLookup, TCP connections, SSL handshakes, content transfers, etc. +- [ ] `ping` indicators including latency, throughput and packets loss. +- [ ] traceroute +- [ ] DNS monitoring + +## Quick Start + +### use as CLI tool + +You can install `hrp` with one shell command, which will download the latest version's released binary and install to the current system. + +```bash +# install via curl +$ bash -c "$(curl -ksSL https://httprunner.oss-cn-beijing.aliyuncs.com/install.sh)" +# install via wget +$ bash -c "$(wget https://httprunner.oss-cn-beijing.aliyuncs.com/install.sh -O -)" +``` + +If you are a golang developer, you can also install `hrp` with `go get`. + +```bash +$ go get github.com/httprunner/hrp/cli/hrp +``` + +Since installed, you will get a `hrp` command with multiple sub-commands. + +```text +$ hrp -h + +██╗ ██╗████████╗████████╗██████╗ ██████╗ ██╗ ██╗███╗ ██╗███╗ ██╗███████╗██████╗ +██║ ██║╚══██╔══╝╚══██╔══╝██╔══██╗██╔══██╗██║ ██║████╗ ██║████╗ ██║██╔════╝██╔══██╗ +███████║ ██║ ██║ ██████╔╝██████╔╝██║ ██║██╔██╗ ██║██╔██╗ ██║█████╗ ██████╔╝ +██╔══██║ ██║ ██║ ██╔═══╝ ██╔══██╗██║ ██║██║╚██╗██║██║╚██╗██║██╔══╝ ██╔══██╗ +██║ ██║ ██║ ██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║██║ ╚████║███████╗██║ ██║ +╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ + +hrp (HttpRunner+) aims to be a one-stop solution for HTTP(S) testing, covering API testing, +load testing and digital experience monitoring (DEM). Enjoy! ✨ 🚀 ✨ + +License: Apache-2.0 +Website: https://httprunner.com +Github: https://github.com/httprunner/hrp +Copyright 2021 debugtalk + +Usage: + hrp [command] + +Available Commands: + boom run load test with boomer + completion generate the autocompletion script for the specified shell + har2case convert HAR to json/yaml testcase files + help Help about any command + run run API test + startproject create a scaffold project + +Flags: + -h, --help help for hrp + --log-json set log to json format + -l, --log-level string set log level (default "INFO") + -v, --version version for hrp + +Use "hrp [command] --help" for more information about a command. +``` + +You can use `hrp run` command to run HttpRunner JSON/YAML testcases. The following is an example running [examples/demo.json][demo.json] + +
+$ hrp run examples/demo.json + +```text +5:21PM INF Set log to color console other than JSON format. +5:21PM ??? Set log level +5:21PM INF [init] SetDebug debug=true +5:21PM INF [init] SetFailfast failfast=true +5:21PM INF [init] Reset session variables +5:21PM INF load json testcase path=/Users/debugtalk/MyProjects/HttpRunner-dev/hrp/examples/demo.json +5:21PM INF call function success arguments=[5] funcName=gen_random_string output=A65rg +5:21PM INF call function success arguments=[12.3,3.45] funcName=max output=12.3 +5:21PM INF run testcase start testcase="demo with complex mechanisms" +5:21PM INF transaction name=tran1 type=start +5:21PM INF run step start step="get with params" +5:21PM INF call function success arguments=[12.3,34.5] funcName=max output=34.5 +-------------------- request -------------------- +GET /get?foo1=A65rg&foo2=34.5 HTTP/1.1 +Host: postman-echo.com +User-Agent: HttpRunnerPlus + + +==================== response =================== +HTTP/1.1 200 OK +Content-Length: 304 +Connection: keep-alive +Content-Type: application/json; charset=utf-8 +Date: Thu, 23 Dec 2021 09:21:30 GMT +Etag: W/"130-t7qE4M7C+OQ0jGdRWkr2R3gjq+w" +Set-Cookie: sails.sid=s%3AAiqfRgMtWKG3oOQnXJOxRD8xk58rtAW6.eD%2BBo7FBnA82XLsLFiadeg6OcuD2zHSTyhv2l%2FDVuCk; Path=/; HttpOnly +Vary: Accept-Encoding + +{"args":{"foo1":"A65rg","foo2":"34.5"},"headers":{"x-forwarded-proto":"https","x-forwarded-port":"443","host":"postman-echo.com","x-amzn-trace-id":"Root=1-61c43f9a-7c855775053963a4284ba464","user-agent":"HttpRunnerPlus","accept-encoding":"gzip"},"url":"https://postman-echo.com/get?foo1=A65rg&foo2=34.5"} +-------------------------------------------------- +5:21PM INF extract value from=body.args.foo1 value=A65rg +5:21PM INF set variable value=A65rg variable=varFoo1 +5:21PM INF validate status_code assertMethod=equals checkValue=200 expectValue=200 result=true +5:21PM INF validate headers."Content-Type" assertMethod=startswith checkValue="application/json; charset=utf-8" expectValue=application/json result=true +5:21PM INF validate body.args.foo1 assertMethod=length_equals checkValue=A65rg expectValue=5 result=true +5:21PM INF validate $varFoo1 assertMethod=length_equals checkValue=A65rg expectValue=5 result=true +5:21PM INF validate body.args.foo2 assertMethod=equals checkValue=34.5 expectValue=34.5 result=true +5:21PM INF run step end exportVars={"varFoo1":"A65rg"} step="get with params" success=true +5:21PM INF transaction name=tran1 type=end +5:21PM INF transaction elapsed=1021.174113 name=tran1 +5:21PM INF run step start step="post json data" +5:21PM INF call function success arguments=[12.3,3.45] funcName=max output=12.3 +-------------------- request -------------------- +POST /post HTTP/1.1 +Host: postman-echo.com +Content-Type: application/json; charset=UTF-8 + +{"foo1":"A65rg","foo2":12.3} +==================== response =================== +HTTP/1.1 200 OK +Content-Length: 424 +Connection: keep-alive +Content-Type: application/json; charset=utf-8 +Date: Thu, 23 Dec 2021 09:21:30 GMT +Etag: W/"1a8-IhWXQxTXlxmnbqdRh+oBPRTLsOU" +Set-Cookie: sails.sid=s%3AzXIPVMKipoISZG0Zj4tX73vKDbIdFtzZ.xD50I4UMHUERmcgWfp64f0a8g%2BT9YIUf0Fi1l5bXbQA; Path=/; HttpOnly +Vary: Accept-Encoding + +{"args":{},"data":{"foo1":"A65rg","foo2":12.3},"files":{},"form":{},"headers":{"x-forwarded-proto":"https","x-forwarded-port":"443","host":"postman-echo.com","x-amzn-trace-id":"Root=1-61c43f9a-78aab84a36a753ea6b5dd0f7","content-length":"28","user-agent":"Go-http-client/1.1","content-type":"application/json; charset=UTF-8","accept-encoding":"gzip"},"json":{"foo1":"A65rg","foo2":12.3},"url":"https://postman-echo.com/post"} +-------------------------------------------------- +5:21PM INF validate status_code assertMethod=equals checkValue=200 expectValue=200 result=true +5:21PM INF validate body.json.foo1 assertMethod=length_equals checkValue=A65rg expectValue=5 result=true +5:21PM INF validate body.json.foo2 assertMethod=equals checkValue=12.3 expectValue=12.3 result=true +5:21PM INF run step end exportVars=null step="post json data" success=true +5:21PM INF run step start step="post form data" +5:21PM INF call function success arguments=[12.3,3.45] funcName=max output=12.3 +-------------------- request -------------------- +POST /post HTTP/1.1 +Host: postman-echo.com +Content-Type: application/x-www-form-urlencoded; charset=UTF-8 + +foo1=A65rg&foo2=12.3 +==================== response =================== +HTTP/1.1 200 OK +Content-Length: 445 +Connection: keep-alive +Content-Type: application/json; charset=utf-8 +Date: Thu, 23 Dec 2021 09:21:30 GMT +Etag: W/"1bd-g4G7WmMU7EzJYzPTYgqX67Ug9iE" +Set-Cookie: sails.sid=s%3Al3gcdxEQug7ddxPlA2Kfxvm7d_z9ImEt.4IQI1SVX5xuTefX0N0UvJPQxVvA1SAMm7ztHESkHXsY; Path=/; HttpOnly +Vary: Accept-Encoding + +{"args":{},"data":"","files":{},"form":{"foo1":"A65rg","foo2":"12.3"},"headers":{"x-forwarded-proto":"https","x-forwarded-port":"443","host":"postman-echo.com","x-amzn-trace-id":"Root=1-61c43f9a-6458626c64b04fd60245714b","content-length":"20","user-agent":"Go-http-client/1.1","content-type":"application/x-www-form-urlencoded; charset=UTF-8","accept-encoding":"gzip"},"json":{"foo1":"A65rg","foo2":"12.3"},"url":"https://postman-echo.com/post"} +-------------------------------------------------- +5:21PM INF validate status_code assertMethod=equals checkValue=200 expectValue=200 result=true +5:21PM INF validate body.form.foo1 assertMethod=length_equals checkValue=A65rg expectValue=5 result=true +5:21PM INF validate body.form.foo2 assertMethod=equals checkValue=12.3 expectValue=12.3 result=true +5:21PM INF run step end exportVars=null step="post form data" success=true +5:21PM INF run testcase end testcase="demo with complex mechanisms" +``` +
+ +### use as library + +Beside using `hrp` as a CLI tool, you can also use it as golang library. + +```bash +$ go get -u github.com/httprunner/hrp +``` + +This is an example of `HttpRunner+` testcase. You can find more in the [`examples`][examples] directory. + + +
+demo + +```go +import ( + "testing" + + "github.com/httprunner/hrp" +) + +func TestCaseDemo(t *testing.T) { + demoTestCase := &hrp.TestCase{ + Config: hrp.NewConfig("demo with complex mechanisms"). + SetBaseURL("https://postman-echo.com"). + WithVariables(map[string]interface{}{ // global level variables + "n": 5, + "a": 12.3, + "b": 3.45, + "varFoo1": "${gen_random_string($n)}", + "varFoo2": "${max($a, $b)}", // 12.3; eval with built-in function + }), + TestSteps: []hrp.IStep{ + hrp.NewStep("transaction 1 start").StartTransaction("tran1"), // start transaction + hrp.NewStep("get with params"). + WithVariables(map[string]interface{}{ // step level variables + "n": 3, // inherit config level variables if not set in step level, a/varFoo1 + "b": 34.5, // override config level variable if existed, n/b/varFoo2 + "varFoo2": "${max($a, $b)}", // 34.5; override variable b and eval again + }). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "$varFoo1", "foo2": "$varFoo2"}). // request with params + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). // request with headers + Extract(). + WithJmesPath("body.args.foo1", "varFoo1"). // extract variable with jmespath + Validate(). + AssertEqual("status_code", 200, "check response status code"). // validate response status code + AssertStartsWith("headers.\"Content-Type\"", "application/json", ""). // validate response header + AssertLengthEqual("body.args.foo1", 5, "check args foo1"). // validate response body with jmespath + AssertLengthEqual("$varFoo1", 5, "check args foo1"). // assert with extracted variable from current step + AssertEqual("body.args.foo2", "34.5", "check args foo2"), // notice: request params value will be converted to string + hrp.NewStep("transaction 1 end").EndTransaction("tran1"), // end transaction + hrp.NewStep("post json data"). + POST("/post"). + WithBody(map[string]interface{}{ + "foo1": "$varFoo1", // reference former extracted variable + "foo2": "${max($a, $b)}", // 12.3; step level variables are independent, variable b is 3.45 here + }). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.json.foo1", 5, "check args foo1"). + AssertEqual("body.json.foo2", 12.3, "check args foo2"), + hrp.NewStep("post form data"). + POST("/post"). + WithHeaders(map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}). + WithBody(map[string]interface{}{ + "foo1": "$varFoo1", // reference former extracted variable + "foo2": "${max($a, $b)}", // 12.3; step level variables are independent, variable b is 3.45 here + }). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.form.foo1", 5, "check args foo1"). + AssertEqual("body.form.foo2", "12.3", "check args foo2"), // form data will be converted to string + }, + } + + err := hrp.NewRunner(nil).Run(demoTestCase) // hrp.Run(demoTestCase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} +``` +
+ +## Subscribe + +关注 HttpRunner 的微信公众号,第一时间获得最新资讯。 + +HttpRunner + +如果你期望加入 HttpRunner 核心用户群,请填写[用户调研问卷][survey]并留下你的联系方式,作者将拉你进群。 + +[HttpRunner]: https://github.com/httprunner/httprunner +[boomer]: https://github.com/myzhan/boomer +[locust]: https://github.com/locustio/locust +[jmespath]: https://jmespath.org/ +[allure]: https://docs.qameta.io/allure/ +[HAR]: http://httparchive.org/ +[hashicorp plugin]: https://github.com/hashicorp/go-plugin +[go plugin]: https://pkg.go.dev/plugin +[demo.json]: https://github.com/httprunner/hrp/blob/main/examples/demo.json +[examples]: https://github.com/httprunner/hrp/blob/main/examples/ +[CHANGELOG]: docs/CHANGELOG.md +[pushgateway]: https://github.com/prometheus/pushgateway +[survey]: https://wj.qq.com/s2/9699514/0d19/ diff --git a/hrp/boomer.go b/hrp/boomer.go new file mode 100644 index 00000000..9dc96277 --- /dev/null +++ b/hrp/boomer.go @@ -0,0 +1,177 @@ +package hrp + +import ( + "sync" + "time" + + "github.com/jinzhu/copier" + "github.com/rs/zerolog/log" + + "github.com/httprunner/funplugin" + "github.com/httprunner/httprunner/hrp/internal/boomer" + "github.com/httprunner/httprunner/hrp/internal/ga" +) + +func NewBoomer(spawnCount int, spawnRate float64) *HRPBoomer { + b := &HRPBoomer{ + Boomer: boomer.NewStandaloneBoomer(spawnCount, spawnRate), + pluginsMutex: new(sync.RWMutex), + } + return b +} + +type HRPBoomer struct { + *boomer.Boomer + plugins []funplugin.IPlugin // each task has its own plugin process + pluginsMutex *sync.RWMutex // avoid data race +} + +// Run starts to run load test for one or multiple testcases. +func (b *HRPBoomer) Run(testcases ...ITestCase) { + event := ga.EventTracking{ + Category: "RunLoadTests", + Action: "hrp boom", + } + // report start event + go ga.SendEvent(event) + // report execution timing event + defer ga.SendEvent(event.StartTiming("execution")) + + var taskSlice []*boomer.Task + for _, iTestCase := range testcases { + testcase, err := iTestCase.ToTestCase() + if err != nil { + panic(err) + } + cfg := testcase.Config + err = initParameterIterator(cfg, "boomer") + if err != nil { + panic(err) + } + rendezvousList := initRendezvous(testcase, int64(b.GetSpawnCount())) + task := b.convertBoomerTask(testcase, rendezvousList) + taskSlice = append(taskSlice, task) + waitRendezvous(rendezvousList) + } + b.Boomer.Run(taskSlice...) +} + +func (b *HRPBoomer) Quit() { + b.pluginsMutex.Lock() + plugins := b.plugins + b.pluginsMutex.Unlock() + for _, plugin := range plugins { + plugin.Quit() + } + b.Boomer.Quit() +} + +func (b *HRPBoomer) convertBoomerTask(testcase *TestCase, rendezvousList []*Rendezvous) *boomer.Task { + hrpRunner := NewRunner(nil) + // set client transport for high concurrency load testing + hrpRunner.SetClientTransport(b.GetSpawnCount(), b.GetDisableKeepAlive(), b.GetDisableCompression()) + config := testcase.Config + + // each testcase has its own plugin process + plugin, _ := initPlugin(config.Path, false) + if plugin != nil { + b.pluginsMutex.Lock() + b.plugins = append(b.plugins, plugin) + b.pluginsMutex.Unlock() + } + + // broadcast to all rendezvous at once when spawn done + go func() { + <-b.GetSpawnDoneChan() + for _, rendezvous := range rendezvousList { + rendezvous.setSpawnDone() + } + }() + + return &boomer.Task{ + Name: config.Name, + Weight: config.Weight, + Fn: func() { + runner := hrpRunner.newCaseRunner(testcase) + runner.parser.plugin = plugin + + testcaseSuccess := true // flag whole testcase result + var transactionSuccess = true // flag current transaction result + + cfg := testcase.Config + caseConfig := &TConfig{} + // copy config to avoid data racing + if err := copier.Copy(caseConfig, cfg); err != nil { + log.Error().Err(err).Msg("copy config data failed") + return + } + // iterate through all parameter iterators and update case variables + for _, it := range caseConfig.ParametersSetting.Iterators { + if it.HasNext() { + caseConfig.Variables = mergeVariables(it.Next(), caseConfig.Variables) + } + } + + if err := runner.parseConfig(caseConfig); err != nil { + log.Error().Err(err).Msg("parse config failed") + return + } + + startTime := time.Now() + for index, step := range testcase.TestSteps { + stepData, err := runner.runStep(index, caseConfig) + if err != nil { + // step failed + var elapsed int64 + if stepData != nil { + elapsed = stepData.Elapsed + } + b.RecordFailure(step.Type(), step.Name(), elapsed, err.Error()) + + // update flag + testcaseSuccess = false + transactionSuccess = false + + if runner.hrpRunner.failfast { + log.Error().Msg("abort running due to failfast setting") + break + } + log.Warn().Err(err).Msg("run step failed, continue next step") + continue + } + + // step success + if stepData.StepType == stepTypeTransaction { + // transaction + // FIXME: support nested transactions + if step.ToStruct().Transaction.Type == transactionEnd { // only record when transaction ends + b.RecordTransaction(stepData.Name, transactionSuccess, stepData.Elapsed, 0) + transactionSuccess = true // reset flag for next transaction + } + } else if stepData.StepType == stepTypeRendezvous { + // rendezvous + // TODO: implement rendezvous in boomer + } else if stepData.StepType == stepTypeThinkTime { + // think time + // no record required + } else { + // request or testcase step + b.RecordSuccess(step.Type(), step.Name(), stepData.Elapsed, stepData.ContentSize) + } + } + endTime := time.Now() + + // report duration for transaction without end + for name, transaction := range runner.transactions { + if len(transaction) == 1 { + // if transaction end time not exists, use testcase end time instead + duration := endTime.Sub(transaction[transactionStart]) + b.RecordTransaction(name, transactionSuccess, duration.Milliseconds(), 0) + } + } + + // report testcase as a whole Action transaction, inspired by LoadRunner + b.RecordTransaction("Action", testcaseSuccess, endTime.Sub(startTime).Milliseconds(), 0) + }, + } +} diff --git a/hrp/boomer_test.go b/hrp/boomer_test.go new file mode 100644 index 00000000..79ffd2fe --- /dev/null +++ b/hrp/boomer_test.go @@ -0,0 +1,34 @@ +package hrp + +import ( + "testing" + "time" +) + +func TestBoomerStandaloneRun(t *testing.T) { + buildHashicorpPlugin() + defer removeHashicorpPlugin() + + testcase1 := &TestCase{ + Config: NewConfig("TestCase1").SetBaseURL("http://httpbin.org"), + TestSteps: []IStep{ + NewStep("headers"). + GET("/headers"). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("headers.\"Content-Type\"", "application/json", "check http response Content-Type"), + NewStep("user-agent"). + GET("/user-agent"). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("headers.\"Content-Type\"", "application/json", "check http response Content-Type"), + NewStep("TestCase3").CallRefCase(&TestCase{Config: NewConfig("TestCase3")}), + }, + } + testcase2 := &demoTestCaseJSONPath + + b := NewBoomer(2, 1) + go b.Run(testcase1, testcase2) + time.Sleep(5 * time.Second) + b.Quit() +} diff --git a/hrp/cmd/boom.go b/hrp/cmd/boom.go new file mode 100644 index 00000000..2751ab52 --- /dev/null +++ b/hrp/cmd/boom.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "time" + + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/hrp/internal/boomer" +) + +// boomCmd represents the boom command +var boomCmd = &cobra.Command{ + Use: "boom", + Short: "run load test with boomer", + Long: `run yaml/json testcase files for load test`, + Example: ` $ hrp boom demo.json # run specified json testcase file + $ hrp boom demo.yaml # run specified yaml testcase file + $ hrp boom examples/ # run testcases in specified folder`, + Args: cobra.MinimumNArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + boomer.SetUlimit(10240) // ulimit -n 10240 + setLogLevel("WARN") // disable info logs for load testing + }, + Run: func(cmd *cobra.Command, args []string) { + var paths []hrp.ITestCase + for _, arg := range args { + path := hrp.TestCasePath(arg) + paths = append(paths, &path) + } + hrpBoomer := hrp.NewBoomer(spawnCount, spawnRate) + hrpBoomer.SetRateLimiter(maxRPS, requestIncreaseRate) + if loopCount > 0 { + hrpBoomer.SetLoopCount(loopCount) + } + if !disableConsoleOutput { + hrpBoomer.AddOutput(boomer.NewConsoleOutput()) + } + if prometheusPushgatewayURL != "" { + hrpBoomer.AddOutput(boomer.NewPrometheusPusherOutput(prometheusPushgatewayURL, "hrp")) + } + hrpBoomer.SetDisableKeepAlive(disableKeepalive) + hrpBoomer.SetDisableCompression(disableCompression) + hrpBoomer.EnableCPUProfile(cpuProfile, cpuProfileDuration) + hrpBoomer.EnableMemoryProfile(memoryProfile, memoryProfileDuration) + hrpBoomer.EnableGracefulQuit() + hrpBoomer.Run(paths...) + }, +} + +var ( + spawnCount int + spawnRate float64 + maxRPS int64 + loopCount int64 + requestIncreaseRate string + memoryProfile string + memoryProfileDuration time.Duration + cpuProfile string + cpuProfileDuration time.Duration + prometheusPushgatewayURL string + disableConsoleOutput bool + disableCompression bool + disableKeepalive bool +) + +func init() { + rootCmd.AddCommand(boomCmd) + + boomCmd.Flags().Int64Var(&maxRPS, "max-rps", 0, "Max RPS that boomer can generate, disabled by default.") + boomCmd.Flags().StringVar(&requestIncreaseRate, "request-increase-rate", "-1", "Request increase rate, disabled by default.") + boomCmd.Flags().IntVar(&spawnCount, "spawn-count", 1, "The number of users to spawn for load testing") + boomCmd.Flags().Float64Var(&spawnRate, "spawn-rate", 1, "The rate for spawning users") + boomCmd.Flags().Int64Var(&loopCount, "loop-count", -1, "The specify running cycles for load testing") + boomCmd.Flags().StringVar(&memoryProfile, "mem-profile", "", "Enable memory profiling.") + boomCmd.Flags().DurationVar(&memoryProfileDuration, "mem-profile-duration", 30*time.Second, "Memory profile duration.") + boomCmd.Flags().StringVar(&cpuProfile, "cpu-profile", "", "Enable CPU profiling.") + boomCmd.Flags().DurationVar(&cpuProfileDuration, "cpu-profile-duration", 30*time.Second, "CPU profile duration.") + boomCmd.Flags().StringVar(&prometheusPushgatewayURL, "prometheus-gateway", "", "Prometheus Pushgateway url.") + boomCmd.Flags().BoolVar(&disableConsoleOutput, "disable-console-output", false, "Disable console output.") + boomCmd.Flags().BoolVar(&disableCompression, "disable-compression", false, "Disable compression") + boomCmd.Flags().BoolVar(&disableKeepalive, "disable-keepalive", false, "Disable keepalive") +} diff --git a/hrp/cmd/doc_test.go b/hrp/cmd/doc_test.go new file mode 100644 index 00000000..82a4f205 --- /dev/null +++ b/hrp/cmd/doc_test.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra/doc" +) + +// run this test to generate markdown docs for hrp command +func TestGenMarkdownTree(t *testing.T) { + err := doc.GenMarkdownTree(rootCmd, "../../docs/cmd") + if err != nil { + t.Fatal(err) + } +} diff --git a/hrp/cmd/har2case.go b/hrp/cmd/har2case.go new file mode 100644 index 00000000..0b37a296 --- /dev/null +++ b/hrp/cmd/har2case.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "errors" + + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/hrp/internal/har2case" +) + +// har2caseCmd represents the har2case command +var har2caseCmd = &cobra.Command{ + Use: "har2case $har_path...", + Short: "convert HAR to json/yaml testcase files", + Long: `convert HAR to json/yaml testcase files`, + Args: cobra.MinimumNArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + setLogLevel(logLevel) + }, + RunE: func(cmd *cobra.Command, args []string) error { + var outputFiles []string + for _, arg := range args { + // must choose one + if !genYAMLFlag && !genJSONFlag { + return errors.New("please select convert format type") + } + var outputPath string + var err error + + har := har2case.NewHAR(arg) + + // specify output dir + if outputDir != "" { + har.SetOutputDir(outputDir) + } + // generate json/yaml files + if genYAMLFlag { + outputPath, err = har.GenYAML() + } else { + outputPath, err = har.GenJSON() // default + } + if err != nil { + return err + } + outputFiles = append(outputFiles, outputPath) + } + log.Info().Strs("output", outputFiles).Msg("convert testcase success") + return nil + }, +} + +var ( + genJSONFlag bool + genYAMLFlag bool + outputDir string +) + +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") +} diff --git a/hrp/cmd/root.go b/hrp/cmd/root.go new file mode 100644 index 00000000..680686ce --- /dev/null +++ b/hrp/cmd/root.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "os" + "runtime" + "strings" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/hrp/internal/version" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "hrp", + Short: "One-stop solution for HTTP(S) testing.", + Long: ` +██╗ ██╗████████╗████████╗██████╗ ██████╗ ██╗ ██╗███╗ ██╗███╗ ██╗███████╗██████╗ +██║ ██║╚══██╔══╝╚══██╔══╝██╔══██╗██╔══██╗██║ ██║████╗ ██║████╗ ██║██╔════╝██╔══██╗ +███████║ ██║ ██║ ██████╔╝██████╔╝██║ ██║██╔██╗ ██║██╔██╗ ██║█████╗ ██████╔╝ +██╔══██║ ██║ ██║ ██╔═══╝ ██╔══██╗██║ ██║██║╚██╗██║██║╚██╗██║██╔══╝ ██╔══██╗ +██║ ██║ ██║ ██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║██║ ╚████║███████╗██║ ██║ +╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ + +hrp (HttpRunner+) aims to be a one-stop solution for HTTP(S) testing, covering API testing, +load testing and digital experience monitoring (DEM). Enjoy! ✨ 🚀 ✨ + +License: Apache-2.0 +Website: https://httprunner.com +Github: https://github.com/httprunner/httprunner/hrp +Copyright 2021 debugtalk`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + var noColor = false + if runtime.GOOS == "windows" { + noColor = true + } + if !logJSON { + log.Logger = zerolog.New(zerolog.ConsoleWriter{NoColor: noColor, Out: os.Stderr}).With().Timestamp().Logger() + log.Info().Msg("Set log to color console other than JSON format.") + } + }, + Version: version.VERSION, +} + +var ( + logLevel string + logJSON bool +) + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "INFO", "set log level") + rootCmd.PersistentFlags().BoolVar(&logJSON, "log-json", false, "set log to json format") + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func setLogLevel(level string) { + level = strings.ToUpper(level) + log.Info().Str("level", level).Msg("Set log level") + switch level { + case "DEBUG": + zerolog.SetGlobalLevel(zerolog.DebugLevel) + case "INFO": + zerolog.SetGlobalLevel(zerolog.InfoLevel) + case "WARN": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + case "ERROR": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + case "FATAL": + zerolog.SetGlobalLevel(zerolog.FatalLevel) + case "PANIC": + zerolog.SetGlobalLevel(zerolog.PanicLevel) + } +} diff --git a/hrp/cmd/run.go b/hrp/cmd/run.go new file mode 100644 index 00000000..ca537149 --- /dev/null +++ b/hrp/cmd/run.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/hrp" +) + +// runCmd represents the run command +var runCmd = &cobra.Command{ + Use: "run $path...", + Short: "run API test", + Long: `run yaml/json testcase files for API test`, + Example: ` $ hrp run demo.json # run specified json testcase file + $ hrp run demo.yaml # run specified yaml testcase file + $ hrp run examples/ # run testcases in specified folder`, + Args: cobra.MinimumNArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + setLogLevel(logLevel) + }, + Run: func(cmd *cobra.Command, args []string) { + var paths []hrp.ITestCase + for _, arg := range args { + path := hrp.TestCasePath(arg) + paths = append(paths, &path) + } + runner := hrp.NewRunner(nil). + SetFailfast(!continueOnFailure). + SetSaveTests(saveTests) + if genHTMLReport { + runner.GenHTMLReport() + } + if !requestsLogOff { + runner.SetRequestsLogOn() + } + if pluginLogOn { + runner.SetPluginLogOn() + } + if proxyUrl != "" { + runner.SetProxyUrl(proxyUrl) + } + err := runner.Run(paths...) + if err != nil { + os.Exit(1) + } + }, +} + +var ( + continueOnFailure bool + requestsLogOff bool + pluginLogOn bool + proxyUrl string + saveTests bool + genHTMLReport bool +) + +func init() { + rootCmd.AddCommand(runCmd) + runCmd.Flags().BoolVarP(&continueOnFailure, "continue-on-failure", "c", false, "continue running next step when failure occurs") + runCmd.Flags().BoolVar(&requestsLogOff, "log-requests-off", false, "turn off request & response details logging") + runCmd.Flags().BoolVar(&pluginLogOn, "log-plugin", false, "turn on plugin logging") + runCmd.Flags().StringVarP(&proxyUrl, "proxy-url", "p", "", "set proxy url") + runCmd.Flags().BoolVarP(&saveTests, "save-tests", "s", false, "save tests summary") + runCmd.Flags().BoolVarP(&genHTMLReport, "gen-html-report", "g", false, "generate html report") +} diff --git a/hrp/cmd/scaffold.go b/hrp/cmd/scaffold.go new file mode 100644 index 00000000..cc7f18a0 --- /dev/null +++ b/hrp/cmd/scaffold.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "errors" + "os" + + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/httprunner/httprunner/hrp/internal/scaffold" +) + +var scaffoldCmd = &cobra.Command{ + Use: "startproject $project_name", + Short: "create a scaffold project", + Args: cobra.ExactValidArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + setLogLevel(logLevel) + }, + RunE: func(cmd *cobra.Command, args []string) error { + if !ignorePlugin && !genPythonPlugin && !genGoPlugin { + return errors.New("please select function plugin type") + } + + var pluginType scaffold.PluginType + if ignorePlugin { + pluginType = scaffold.Ignore + } else if genGoPlugin { + pluginType = scaffold.Go + } else { + pluginType = scaffold.Py // default + } + err := scaffold.CreateScaffold(args[0], pluginType) + if err != nil { + log.Error().Err(err).Msg("create scaffold project failed") + os.Exit(1) + } + log.Info().Str("projectName", args[0]).Msg("create scaffold success") + return nil + }, +} + +var ( + ignorePlugin bool + genPythonPlugin bool + genGoPlugin bool +) + +func init() { + rootCmd.AddCommand(scaffoldCmd) + 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") +} diff --git a/hrp/convert.go b/hrp/convert.go new file mode 100644 index 00000000..94bd3328 --- /dev/null +++ b/hrp/convert.go @@ -0,0 +1,256 @@ +package hrp + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" + + "github.com/httprunner/httprunner/hrp/internal/json" +) + +func loadFromJSON(path string, structObj interface{}) error { + path, err := filepath.Abs(path) + if err != nil { + log.Error().Str("path", path).Err(err).Msg("convert absolute path failed") + return err + } + log.Info().Str("path", path).Msg("load json") + + file, err := os.ReadFile(path) + if err != nil { + log.Error().Err(err).Msg("load json path failed") + return err + } + + decoder := json.NewDecoder(bytes.NewReader(file)) + decoder.UseNumber() + err = decoder.Decode(structObj) + return err +} + +func loadFromYAML(path string, structObj interface{}) error { + path, err := filepath.Abs(path) + if err != nil { + log.Error().Str("path", path).Err(err).Msg("convert absolute path failed") + return err + } + log.Info().Str("path", path).Msg("load yaml") + + file, err := os.ReadFile(path) + if err != nil { + log.Error().Err(err).Msg("load yaml path failed") + return err + } + + err = yaml.Unmarshal(file, structObj) + return err +} + +func convertCompatValidator(Validators []interface{}) (err error) { + for i, iValidator := range Validators { + validatorMap := iValidator.(map[string]interface{}) + validator := Validator{} + _, checkExisted := validatorMap["check"] + _, assertExisted := validatorMap["assert"] + _, expectExisted := validatorMap["expect"] + // check priority: HRP > HttpRunner + 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) + } + validator.Check = convertCheckExpr(validator.Check) + Validators[i] = validator + } else if len(validatorMap) == 1 { + // HttpRunner validator format + for assertMethod, iValidatorContent := range validatorMap { + checkAndExpect := iValidatorContent.([]interface{}) + if len(checkAndExpect) != 2 { + return fmt.Errorf("unexpected validator format: %v", validatorMap) + } + validator.Check = checkAndExpect[0].(string) + validator.Assert = assertMethod + validator.Expect = checkAndExpect[1] + } + validator.Check = convertCheckExpr(validator.Check) + Validators[i] = validator + } else { + return fmt.Errorf("unexpected validator format: %v", validatorMap) + } + } + return nil +} + +func convertCompatTestCase(tc *TCase) (err error) { + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("convert compat testcase error: %v", p) + } + }() + for _, step := range tc.TestSteps { + // 1. deal with request body compatible with HttpRunner + 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 + } else if step.Request.Data != nil { + step.Request.Body = step.Request.Data + } + } + + // 2. deal with validators compatible with HttpRunner + err = convertCompatValidator(step.Validators) + if err != nil { + return err + } + } + return nil +} + +// convertCheckExpr deals with check expression including hyphen +func convertCheckExpr(checkExpr string) string { + if strings.Contains(checkExpr, textExtractorSubRegexp) { + return checkExpr + } + checkItems := strings.Split(checkExpr, ".") + for i, checkItem := range checkItems { + if strings.Contains(checkItem, "-") && !strings.Contains(checkItem, "\"") { + checkItems[i] = fmt.Sprintf("\"%s\"", checkItem) + } + } + return strings.Join(checkItems, ".") +} + +func (tc *TCase) ToTestCase() (*TestCase, error) { + testCase := &TestCase{ + Config: tc.Config, + } + for _, step := range tc.TestSteps { + if step.APIPath != "" { + path := filepath.Join(filepath.Dir(testCase.Config.Path), step.APIPath) + refAPI := APIPath(path) + step.APIContent = &refAPI + apiContent, err := step.APIContent.ToAPI() + if err != nil { + return nil, err + } + step.APIContent = apiContent + testCase.TestSteps = append(testCase.TestSteps, &StepAPIWithOptionalArgs{ + step: step, + }) + } else if step.TestCasePath != "" { + path := filepath.Join(filepath.Dir(testCase.Config.Path), step.TestCasePath) + refTestCase := TestCasePath(path) + step.TestCaseContent = &refTestCase + tc, err := step.TestCaseContent.ToTestCase() + if err != nil { + return nil, err + } + step.TestCaseContent = tc + testCase.TestSteps = append(testCase.TestSteps, &StepTestCaseWithOptionalArgs{ + step: step, + }) + } else if step.ThinkTime != nil { + testCase.TestSteps = append(testCase.TestSteps, &StepThinkTime{ + step: step, + }) + } else if step.Request != nil { + testCase.TestSteps = append(testCase.TestSteps, &StepRequestWithOptionalArgs{ + step: step, + }) + } else if step.Transaction != nil { + testCase.TestSteps = append(testCase.TestSteps, &StepTransaction{ + step: step, + }) + } else if step.Rendezvous != nil { + testCase.TestSteps = append(testCase.TestSteps, &StepRendezvous{ + step: step, + }) + } else { + log.Warn().Interface("step", step).Msg("[convertTestCase] unexpected step") + } + } + return testCase, nil +} + +var ErrUnsupportedFileExt = fmt.Errorf("unsupported testcase file extension") + +// APIPath implements IAPI interface. +type APIPath string + +func (path *APIPath) ToString() string { + return fmt.Sprintf("%v", *path) +} + +func (path *APIPath) ToAPI() (*API, error) { + api := &API{} + var err error + + apiPath := path.ToString() + ext := filepath.Ext(apiPath) + switch ext { + case ".json": + err = loadFromJSON(apiPath, api) + case ".yaml", ".yml": + err = loadFromYAML(apiPath, api) + default: + err = ErrUnsupportedFileExt + } + if err != nil { + return nil, err + } + err = convertCompatValidator(api.Validators) + return api, err +} + +// TestCasePath implements ITestCase interface. +type TestCasePath string + +func (path *TestCasePath) ToString() string { + return fmt.Sprintf("%v", *path) +} + +func (path *TestCasePath) ToTestCase() (*TestCase, error) { + tc := &TCase{} + var err error + + casePath := path.ToString() + ext := filepath.Ext(casePath) + switch ext { + case ".json": + err = loadFromJSON(casePath, tc) + case ".yaml", ".yml": + err = loadFromYAML(casePath, tc) + default: + err = ErrUnsupportedFileExt + } + if err != nil { + return nil, err + } + err = convertCompatTestCase(tc) + if err != nil { + return nil, err + } + tc.Config.Path = path.ToString() + testcase, err := tc.ToTestCase() + if err != nil { + return nil, err + } + return testcase, nil +} + +func (path *TestCasePath) ToTCase() (*TCase, error) { + testcase, err := path.ToTestCase() + if err != nil { + return nil, err + } + return testcase.ToTCase() +} diff --git a/hrp/convert_test.go b/hrp/convert_test.go new file mode 100644 index 00000000..4e7ed7c2 --- /dev/null +++ b/hrp/convert_test.go @@ -0,0 +1,69 @@ +package hrp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + demoTestCaseJSONPath TestCasePath = "../examples/hrp/demo.json" + demoTestCaseYAMLPath TestCasePath = "../examples/hrp/demo.yaml" + demoRefAPIYAMLPath TestCasePath = "../examples/hrp/ref_api_test.yaml" + demoRefTestCaseJSONPath TestCasePath = "../examples/hrp/ref_testcase_test.json" + demoThinkTimeJsonPath TestCasePath = "../examples/hrp/think_time_test.json" + demoAPIYAMLPath APIPath = "../examples/hrp/api/put.yml" +) + +func TestLoadCase(t *testing.T) { + tcJSON := &TCase{} + tcYAML := &TCase{} + err := loadFromJSON(demoTestCaseJSONPath.ToString(), tcJSON) + if !assert.NoError(t, err) { + t.Fail() + } + err = loadFromYAML(demoTestCaseYAMLPath.ToString(), tcYAML) + if !assert.NoError(t, err) { + t.Fail() + } + + if !assert.Equal(t, tcJSON.Config.Name, tcYAML.Config.Name) { + t.Fail() + } + if !assert.Equal(t, tcJSON.Config.BaseURL, tcYAML.Config.BaseURL) { + t.Fail() + } + if !assert.Equal(t, tcJSON.TestSteps[1].Name, tcYAML.TestSteps[1].Name) { + t.Fail() + } + if !assert.Equal(t, tcJSON.TestSteps[1].Request, tcYAML.TestSteps[1].Request) { + t.Fail() + } +} + +func Test_convertCheckExpr(t *testing.T) { + exprs := []struct { + before string + after string + }{ + // normal check expression + {"a.b.c", "a.b.c"}, + {"headers.\"Content-Type\"", "headers.\"Content-Type\""}, + // check expression using regex + {"covering (.*) testing,", "covering (.*) testing,"}, + {" (.*) a-b-c", " (.*) a-b-c"}, + // abnormal check expression + {"-", "\"-\""}, + {"b-c", "\"b-c\""}, + {"a.b-c.d", "a.\"b-c\".d"}, + {"a-b.c-d", "\"a-b\".\"c-d\""}, + {"\"a-b\".c-d", "\"a-b\".\"c-d\""}, + {"headers.Content-Type", "headers.\"Content-Type\""}, + {"body.I-am-a-Key.name", "body.\"I-am-a-Key\".name"}, + } + for _, expr := range exprs { + if !assert.Equal(t, convertCheckExpr(expr.before), expr.after) { + t.Fail() + } + } +} diff --git a/hrp/docs/CHANGELOG.md b/hrp/docs/CHANGELOG.md new file mode 100644 index 00000000..7d88d755 --- /dev/null +++ b/hrp/docs/CHANGELOG.md @@ -0,0 +1,142 @@ +# Release History + +## v0.8.0 (2022-03-22) + +- feat: support hashicorp python plugin over gRPC +- feat: create scaffold with plugin option, `--py`(default), `--go`, `--ignore-plugin` +- feat: print statistics summary after load testing finished +- feat: support think time for api/load testing +- fix: update prometheus state to stopped on quit + +## v0.7.0 (2022-03-15) + +- feat: support API layer for testcase #94 +- feat: support global headers for testcase #95 +- feat: support call referenced testcase by path in YAML/JSON testcases +- fix: decode failure when content-encoding is deflate +- fix: unstable RPS when load testing in high concurrency + +## v0.6.4 (2022-03-10) + +- feat: both support gRPC(default) and net/rpc mode in hashicorp plugin, switch with environment `HRP_PLUGIN_TYPE` +- refactor: move submodule `plugin` to separate repo `github.com/httprunner/funplugin` +- refactor: replace builtin json library with `json-iterator/go` to improve performance + +## v0.6.3 (2022-03-04) + +- feat: support customized setup/teardown hooks (variable assignment not supported) +- feat: add flag `--log-plugin` to turn on plugin logging +- change: add short flag `-c` for `--continue-on-failure` +- change: use `--log-requests-off` flag to turn off request & response details logging +- fix: support posting body in json array format +- fix: testcase format compatibility with HttpRunner + +## v0.6.2 (2022-02-22) + +- feat: support text/html extraction with regex +- change: json unmarshal to json.Number when parsing data +- fix: omit pseudo header names for HTTP/1, e.g. :authority +- fix: generate `headers.\"Content-Type\"` in har2case +- fix: incorrect data type when extracting data using jmespath +- fix: decode response body in brotli/gzip/deflate formats +- fix: omit print request/response body for non-text content +- fix: parse data for request cookie value + +## v0.6.1 (2022-02-17) + +- change: json unmarshal to float64 when parsing data +- fix: set request Content-Type for posting json only when not specified +- fix: failed to generate API test report when data is null +- fix: panic when assertion function not exists +- fix: broadcast to all rendezvous at once when spawn done + +## v0.6.0 (2022-02-08) + +- feat: implement `rendezvous` mechanism for data driven +- feat: upload release artifacts to aliyun oss +- feat: dump tests summary for execution results +- feat: generate html report for API testing +- change: remove sentry sdk + +## v0.5.3 (2022-01-25) + +- change: download package assets from aliyun OSS +- fix: disable color logging on Windows +- fix: print stderr when exec command failed +- fix: build hashicorp plugin failed when creating scaffold + +## v0.5.2 (2022-01-19) + +- feat: support creating and calling custom functions with [hashicorp/go-plugin](https://github.com/hashicorp/go-plugin) +- feat: add scaffold demo with hashicorp plugin +- feat: report events for initializing plugin +- fix: log failures when the assertion failed + +## v0.5.1 (2022-01-13) + +- feat: support specifying running cycles for load testing +- fix: ensure last stats reported when stop running + +## v0.5.0 (2022-01-08) + +- feat: support creating and calling custom functions with [go plugin](https://pkg.go.dev/plugin) +- feat: install hrp with one shell command +- feat: add `startproject` sub-command for creating scaffold project +- feat: report GA event for loading go plugin + +## v0.4.0 (2022-01-05) + +- feat: implement `parameterize` mechanism for data driven +- feat: add multiple builtin assertion methods and builtin functions + +## v0.3.1 (2021-12-30) + +- fix: set ulimit to 10240 before load testing +- fix: concurrent map writes in load testing + +## v0.3.0 (2021-12-24) + +- feat: implement `transaction` mechanism for load test +- feat: continue running next step when failure occurs with `--continue-on-failure` flag, default to failfast +- feat: report GA events with version +- feat: run load test with the given limit and burst as rate limiter, use `--spawn-count`, `--spawn-rate` and `--request-increase-rate` flag +- feat: report runner state to prometheus +- refactor: fork [boomer] as submodule initially and made a lot of changes +- change: update API models + +## v0.2.2 (2021-12-07) + +- refactor: update models to make API more concise +- change: remove mkdocs, move to [repo](https://github.com/httprunner/httprunner.github.io) + +## v0.2.1 (2021-12-02) + +- feat: push load testing metrics to [Prometheus Pushgateway][pushgateway] +- feat: report events with Google Analytics + +## v0.2.0 (2021-11-19) + +- feat: deploy mkdocs to github pages when PR merged +- feat: release hrp cli binaries automatically with github actions +- feat: add Makefile for running unittest and building hrp cli binary + +## v0.1.0 (2021-11-18) + +- feat: full support for HTTP(S)/1.1 methods +- feat: integrate [zerolog](https://github.com/rs/zerolog) for logging, include json log and pretty color console log +- feat: implement `har2case` for converting HAR to JSON/YAML testcases +- feat: extract and validate json response with [`jmespath`][jmespath] +- feat: run JSON/YAML testcases with builtin functions +- feat: support testcase and teststep level variables mechanism +- feat: integrate [`boomer`][boomer] standalone mode for load testing +- docs: init documentation website with [`mkdocs`][mkdocs] +- docs: add project badges, including go report card, codecov, github actions, FOSSA, etc. +- test: add CI test with [github actions][github-actions] +- test: integrate [sentry sdk][sentry sdk] for event reporting and analysis + +[jmespath]: https://jmespath.org/ +[mkdocs]: https://www.mkdocs.org/ +[github-actions]: https://github.com/httprunner/hrp/actions +[boomer]: github.com/myzhan/boomer +[sentry sdk]: https://github.com/getsentry/sentry-go +[pushgateway]: https://github.com/prometheus/pushgateway diff --git a/hrp/docs/README.md b/hrp/docs/README.md new file mode 100644 index 00000000..55d864b5 --- /dev/null +++ b/hrp/docs/README.md @@ -0,0 +1,9 @@ +# Links + +- Homepage: https://httprunner.com +- Docs + - Repo: https://github.com/httprunner/httprunner.github.io + - 中文: https://httprunner.com/docs + - English: https://httprunner.com/en/docs + - [hrp command help](cmd/hrp.md) +- Blog: https://httprunner.com/blog diff --git a/hrp/docs/assets/flow.jpg b/hrp/docs/assets/flow.jpg new file mode 100644 index 0000000000000000000000000000000000000000..985158531b0442cae1b78c33b8fba06a57245b6a GIT binary patch literal 219308 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

d+m+@&^E&3@~I{$%kCFL zDgf%j2(QJm+*1QKm;eaeUhAf3Cj@%(w>|c-K#ZtWplk8KzL7!f_yb49i&_3cfu%yY z(?7)Rl-nj1|6VGTQS|gD{8K-L2bY7;SE(aPN=b`1YNB`X-!h62G!TuK&Lu~({^|lR z=_nS^EiaYDDXggNF5uQZash{ukN5z_AW-R50mZFl{S4GoZHJ!WRH`*Y~%t z8)WR4XD#4ZRYw)DxPTr9BCZN-=p7-RwqxY=)gxt?D2~_~erpWT?yq0W}g$7H` z|5~i0-5+pN69$6sVW{VpMuEv34=ha1ruRoQI-$p4ihJbRDXfuN39RuC=Q7*GU}7u~ zj?4JyL(8Y<9(YZi&`Vdm-)G6_8u00eyUvVgol=wIeV+RHxUy!EcrAm4+H$iZU3U>4`HDm$6~-39mjY=GUfM~(nN6&vY(UKsc?8t*a zy08wT~>+%IsfY?DnB9 z)_}}vH=PdHP@SK$-4ICspU<#6FjqH5A6R0J;L;YaaK@TVhHbB9Pxttm+UbR_uXnnx zgR9{C3Se;MPHb93AsZVBTN41KKye@YeYBXEqrO?~OToWOL%hbCGZxm#+qkoS*?Y$OxjVcI6^89JN)*NG9Y?h3w+%fQ568?} zhbH`^W%A#jtuCep<}>8@S4I81yIEve@}aZY*b1g z%y&8dz~=-A+Tv+X<9v5?@evc+TM(%Tj=h#`W#`C}hVimYl1 z@BfqW{Xq)<{U;>_tp?%A2c#ArwIHtPFr)jrM9uc%P@qML+d4@9_affkaJ@rvv9*<4@hCzUz-x0{x@kFhy3%l&1;iJkhjI<(Hp21-H#ty}O`lFJWFv_@#3kHdc)@3J4Ifq1!l4?VbPDd27{@i3z!W z>1q3mdmOmO{t^D$$N~RZLUX;FK=23aDozg+)1Aj>2KrmRqE0|FwbJxmnfIK%#}gRT z5I=>GxlrTuyEfvB2%=6Mk*kxXSA#}KGl`)2 z&dH*9Wv!f5!rb`Ruhlc_=b9x$>{PgVmSPF8Zn2y?UjLs%VJ`&Yz(>H+`&xAjy(!?W z9Pwq?362I2AOql%bBSG? z8OXX|AV_heXUw75i8VKbfOm{#N6dHtXD4ugizu$(AARBoi4;;j{jMk>OMbx<$#XD!Ant{Bc|TAP9F#^P%hi^N zpa5V?!f=_^>HcS8NO=SaytfrS6a44188wiQEQ)1x`dh{OM?b;O7-swz^ZGX@^24di z`R64mpZ|LG=v~X>p$3rFJPbdtv1kv|4C40ty=QOw20rq51_l?Ga8>r5$nnKph83g> zU<7LhH1dA$)LsG6fS1r}IaUqhZem9ZW97HsKL+WRA{HWzE(6Ejl9RJSu{LY1DN?Mm zDfMxWymjF1W}dY#Du*!N!1a&h#eXCRwIYbMLv(YSfrn*~^4zD3@*>u3*L2g#f!&ZgS`KFx?9tI~ zJMHekQ}E(JVl)^T>O3DE!zKG7*TG!$G(6YYFmyc4j44V_`NBuALA%hDr1~f0_%{Q9 zsgA9IaBTIX*!R5q0JTsH?2YsxZ`?B#-^D_PRK#83&M53AIzz-IEJy({hl85`AZ(7o zd1o;6l0#w&-#Q#HK_~!D%t2hwgRA1JLfuZ-<|@LaaHPe|OK&C}0DJ%p#6OHU9G7xJ zKAqF%r%Utr|6CV8(TE5r%pP|}{$9xX-8?%~|N4v|+RYM(^tK-E(2VP?gxy`(428ov zVvmK_%L}Lpl4BLeqhqXb`qqig>u2lv`6Pvpe9{(klG!e_o1fMhKe#pf=g1OhnKUE( zYM23GQE^p=Wx=QgLQ21ZMg{!-CF9a|b5QxSC(~>wm(!Y;_ocDw*VtL>#;;pQKQ>Rn z|2Pzoa=~$USEwmf^4dN+=_?PdBTenj4-ngzptWDq>kgIivtnv~eH2a7-0unP8*|dp zmMcG6%>zEAH#a@>`K%%Kyuo0zB-G1N^G64C!f#5~x(PZf^8gMbwV&;HhgPA9a5X!%Tr57^s}A z58HlihDli+l})+{Y5vY@m!R-&-NUQDn$mNSYxwjVoKi8pVRt-`TnlAvn!EUtXHZnk(4;wNenoAz)Itwg2H-YQ=w^UP?Uh<#s2jt6|QdU+D|}Y7W&LXa*n7BNH)(5oHI-&q)gzHPChuI@A9QRd8dk6`)t~%hp^m zkW)xy)ZDRnA_#&5zW3&#{?N-*@i90P%m!pz??Z$0UtS^ijg?F75yy6I!?O`71pB&G z^D=O7Duht*!jB#?p#JYHWb=;{F?6Z3B~pWe5(UCv7k5M?Im9kLj8DcAqWQX-ka~;` z(VWgHfB`o=7xIHb(6q463C3W62;=BBavij}l_3r3wjy;D3f}Y%`1FAd$X%(i`;V-+ zWgMx6wxs(5xJ#N2GV8vS`@NOUF>b$uF(mu_h$H z#7T4EQ;c~pwjl(B7u+M3L)`z@g#pBuz%wMrx+`->4yopA7OeeTu;}D;i`E>3?506>c_-YJ2wE68>Yjl;WCSv2kT;;FiX%jTDB4?#ckqTLIebhcEcjI!4x zXRb_&G>&LV-)KNoDW8PR`aj;5dK=dE4jVoNk-7zN!jyOfOumDs&B~Q1XKQvg!(dq1 z1Sq$4b-yn*V^Uxp>a$T<;QIN=CC`p4>uq4zk91G(nRRwqB#}F!MR@-St_#kJH$yBz z)8T5g?(D-l%wk_-Q@;Q_w`1MNqNg7G6A!|hU;N}8GGA7zbPOt%LHa-#v?;b7Mhw}Y zGrMAPWSKXWpn;lNtMdC%$AR)Yy`T2Sn8EaQ0Xm+;&0NoO6$_EdgGh za#K$1=yRHVR|{D4N$4nc!1dlXCF-YxBC6X$8x0YNDgsbiO^CFR3AuQ`>eJd65B)eBUr?2u3H-< zJPMYaUL_gYfOBfgJ1_H;Xr!s1TZ_>$>xyXheR9PNZs}iVrLU7HE` z-FdbbyvX*ifQU$OVG*SD-@VfNeGmtZnUp!q0>)hGw*iZ+-H}W>_BvO}p)DmaDz+VgOvjJ5#V* zVHnyFS{i|aES_d90y0Nn?+48s#du4Bw-t2lqrZk`DqlK{S)^JK0^3B|%FjBaz<@gt zFtov5mS=@$-_!8rbK7LEq*>=s&DH_4pY zV#cz;Q?crkfU+Bi2JSKxz2o{7hqT5KHf|Pa?8C!bO4lE-BU=cISb6~Mw$XAr>9Erm z!9nG~Tk@0*Xm>up#AHVEBnvy@pO+uAi*tJaYAvmA27!<_&$3LVN+8B+F~RKlVI zkt1Lf9RX}E=zhxp zCwxr+XLdnY=j43Mo|wN4+y8Mc&tM=E)^6N`^nUW-V1ya~h`d<9kn~6?5JG$lfNC}$ zz@*FfMDejbY)Xg-*}fm?(~V?!0IXgDr3o#O9FPQK6vVuAEp#%nx$8o(C&6e>yad!m z5a@9~%Iu!CDeX{A;Fd2CYf>lJQCbB_vROWm-@=ZTJ-4=e7MY2B{m;{Y&QH9&+p(9) z(z!6H&-eFfL~s@xPm{IB?f;6B@MMQJY%5_QL=LPkCTuOC#1=t%kjdR5vqKO$e7C7s zrk!>Dz7u==K}g~h{deK3R*%&Um|w_!xOfH&$5etvHv0^m!36NSvOt)6R$y`Z^s6!C zLw6w|Y5X|-5U7dj+-Sf*1fn=3lVH!kgv__h5y~ONSrBq*4KHDqa<8e>NZGU2l~>j) zUOsxb{}2X*;j|IeC2hEXMG za~mni*lp?1DX=|&YR^SZ?D~slFNPqx+P6-C)!$ta@nE|uv1Hk~m{Q=xS4O4CI5ywp?;8s{>J-kz=e54;3(QJMz!ESv**(|*?qIaBb``W0LNcMNEsjFp+U}*<7qsy?eE&1;{GSzP4L1sr24hHx zji6aI0MtnKmVMVS8O0e;QoIJ*a^nHHI?;<4LB5Fl1K)l~!EB{zZ$+lqN4AE%Gk}r& zwrCsoNlhx!f|yOG;g`+;-bbzs(fNMdi5>D%G6|#H{(B;tRjGrW5cJJh`D`YJ&sO70 zHCyHE>O#VE_Q|`nsS{pAzXa+hnC&J)x8Q~OjlEvbWaL45-g<7X|#hU>1U>kdi3nH2h8(U=l|E-=avhq|R*zceYDkzS6O zCXh*OS-tb^I!G2VAO^tVr!>C`ZDhRV7)8U@3)7#J`bsjS9m2lTq6(056ShRkugHZ| zw1F^9`yi4CL8%En;D67z-8r|C(}1oard(itn*bSEn0kBlf4&VU`|ZE77)R6!3)!>HOT`I=&?)3A0MT^` z1WL{An?=9_A#L>9;?U_}zry<(=)SMuQH0h6+NU#OMx;*O`G**eA zjpDf1A|XxybqM^4^C9Y^Ot$ZtMYlpJ&{t^&+Ch0dffx>-nu&Ksx%Wg4Wls8vH&)&1 zr>1yX!kj78c30w{vCPn$OBcSeG!8q4WDOS5DV>c5(DpEn4VoZRqZgM_>sv$lTe zekq%56$k|Za6>pb2XM)FHGMSNenY&0P5O?_bL%Js6tpobG!8_DJL)oO_Ty_~?5>m0 z2d4RUWUbBo`dM12kW*bDmcKy7tBX7D$#>#l<~--2(Wkqn zmHa@*1e)GV74-n-_+!TYRCwxCoK%Cs9g73gR9>#Qy!7FF0yjJKkd$DSLg{?-d|}k) z@cq-}kts-e4xP!aI3935a68i-ob41RB|@REx&QUt{AWF(cBllROEba5QA(tK8rC{l zujYbuLS<}3Hs&?Ac$YS+8t=*BV%+w5SAFs|O+~{`g+ufTE@d?=o*c6NaM{lX{QePe ze}ACd;q{fJ&L43TyOf^yO~HPPS32*c%45l!*LaOL{iEyf8=y#WXfKf z>Jk$Q<9$~C`gh=$I@{Kh!z`ute0kS-T4JJD#dr5}iYBd}ukes0!2m-H!A_rncIeu< zsSl+OS+NZVX||amsSdO=1od~CUg5jS!Lx0%{G~WxIPHaRxqlZXms)-3uQdB4ef8T0 zcPs=&dn+R#!^>ax{58Zu)9G#I9n*7Fy#^14j&OX}E!B0(69Sd6qUjrUs!1CM$O$K9 z+8UC`Lv29UM{JAg{*y9OD0SsN>UqL>G}%9ODRC)lBhD|{r|pH$jQ<%_VCWtlMlv}> zK)CqA@85vm@l{|iamW%cS^{AX9NUH9Y^6GU6552<`h6K zN7p14UKS~NOKRjvM&B>EPnTRDOnnqkkek@=prrA4#UQCV?(l)r4bkqUOpid1HY6&3r@}(!$~jsyLxm`Fd>{l%sV%Wz%JiI7mF-k`e!WA2R$4Ceo|IckpmT`G(1`!Y6gqXU1Jicc#y{1@b~hwoXd{)8YS z2s~3jy-adKp1+^>W;RCPoFgW1ve)zO;fZ`X!YX4r8;fOwNfvfO8i560G#rJ~OAt=r zcLB;S`obxTG8ZBw+E22Acg(wNUPKy+aEZN8Y+h(P``)Vc!e&bCF!c!@FArLLD_3Co zpm}IHoCCMdZSw0r&xg^`<~`7T&v$$ofAok(yhuL##Gz@2p&fnjO{~do zUg!_CaWRH3p^^2Q`_Sm@4J$ei52dRl={3=1sBH)YfQ#lcW7819#V(G&Gr=J`gaGg} zgFS#c4qJZ9eSJ7e5+-JPH5S7R|Ac1PQp@@^u4z?99}Rw7wDjgic3w|35{8T_tAEP& zn@hNEpCv!HEJ%v_#0-UO{gk(IaHaZ(z8}k=@>c_|`%y z-vOyh?Epk6DX`8v%X0Xtk3HEW6eAh-6qKR9PZqUbv3~$cRA#Zk-0fMmI5Txejcq#u zg$+}iVU|N)MCI2$cL?0#r&5*97fb|jo~NcF;*H$SA8wnWAG?3x2T>4?%U^Q>tgCKQ zLm?wI5TalV$*MmP!(*5y%)juhqoC_;%RkwBiO68tYwKGc1r`T_+ySx&&_=7qb=2mT z)xc#QZIZnvNtonx8#q8Zj>Gkjt2;iQ&BRe+u_$3onl1FQI=jJ2Vd=AH(#D|P?Y`6@P#B0BGaz508(ET>U1+S*#*`8ptYPV|{v4F%Bb++NA+FgL zk_E*-UANC;-rck$&E3aJk_pTfm0nW_vnmfTI$Hr0;0`xn+(Iu~GjuUES@Ep>E+1X9 zf7W1FU~>(cnzCk)tpjAv%{U~vK(nfjlUaw1Yv*fjO=0w{n{ny{4B-p+cde`I9dw?a9SJ)vNK6-f z?S}$R-22z%C^`lg^J6ClvB^XyP2c;-sP6*Ob5ZkOY3RZKcpR($zP$arPHPBR8dwkQ zE5t}Hwaeav+4r@DXxe)bow&vMcSjC2>eJbj5l*E77?K23;<#%(S7P_ZI!U51W>-c0 zq46Q$-Fx*<^@5Nt-9fuLwq*gi@u|V)gy3X8roO>5{~)!LGk+t_fKI6H24!UZ@1i^r zP;={&K;&h4!{lb6VLSJjAxVVx2&OgbfK9h^>0Tzcp3>~QorE;T*QAZ^mxVu@c3ynM z(nE1(j1;m?$d$a^HgAke*~^{M=ZxUf)wnSv)!n6F1d>g=0Zsp&A6a_fD@+@U_lLwT ztSn8~y%pf3X~oHEJPYx)g$gE#8{D3I*z#Oj(-iJicZ0dDp~=<-Sg3gzUuzrje$Dve zs!y8}u#JzlVb6->cr-ebt+tx5%s1ELQeNTY5i64FdaZH)%R?a__ArkE7MTYn`1r@o zkVR~SdHz6SBJS#2@kWDe8NZ%C^SXhyES|ki_d54=2CG*8QRr#qA1qtA*Ln9$|J6<) zm0O8b#s1=%NP~&}eY7qNrN@G+*f=k2_4aVA$2<aAH`WTB5;jm^gIrDJ2@2Pk$SWol%04#~O?oDO!G*(MpKMZ9Lq&eoStn zAkoI*+;jb$IT3>gf5TP11OqwoPf3Olt(FSJo08-B=eob%zGI;$})5Nzw_|FC71)fT^yC-(048*S+`e!7I)-+zc%$8LB`mtwx;7 zqef;x)3T{k>-aUaga}36S4B=4q;x&Dt-6^%NASKc9^sd_0Ht6J7j8kn&MXU^a-+@? zDH*+T!(HDMSTUS&dT%mScjNK+?6!Q$k8GfyZb*?szaLmydj_Y%{eod(VsoDYb1d@g zCg`jfr`a$MqFZh$aETz+7|+;cA$A@@#JH-w_>%%ckKi^T`B?8EkHGZK*!;4iG1kA{A7vy39UK4}!@5NxWqKC568-xl1 z=vnMj%6CJd?tGlMrO_a1_~aK@5%{trZC+7KxkU|k0c;CV+{^_R99L{JI=p+%AaoO$ zIXJY#fr}ZV7znB-#Z>I7yHv>s0RfsTo#6E+oe(qVoot1LcA`v9r8jeX-Cde74s6Ko zK&Xk3z|Up+ItJSYoR!)eflwIh10J$o7!kgrX$xg>N3ofts+<~aJ>+bkL8PqX0XRv= zLaKZasY$_KYvnm0QTmtTF2;uKh1%%EFA%JVhvnLqqYO+e((i5net*u^GmT6#G$2-_ zgcmYvKk|R8yih>Bc=ioO4H5b6YSVMSMM(=mh2H>w>H1AL3GE|aXy>lq{&<)Eqe-hA zlu16IBDW-~3TC;LIk`5lI;YWytcpP+4V zS>S5(K5rbyJ|s#(ZGAa>)}I-(KM?BB!a^)Q%{IBo`}I{(HOB3K$UX&l%HFe879MQb zhZpa8K_?K(%tV+&8`EpV(2Zs(BW*E)F&Y=TRRI%LDquVnc364&FlB+SV;2aMD!8jM@EXqjv z_x`CB_K%COpRPffVpF|5yiG4jB4q;{hFJ<+mdB*ED843m#JrwM#k1?8+x-C2aURHKB37+h@P=;=Ut?%L1N1GWvU zdo9h+eQqXFd$l$~Qy?TX2w}$&Vgo{UJHn&WpifN&efaDp$a~)iokDtFMILm;GL;c2 z(0k(tNzJ$rEK3W3nU--KL5<;4NV`{O5Tb4B?KQw{&;6V~iCtpN$TYFrU}HhiSgJsK z%j5fg581x{+(R?SjSac-=EHZA5q>;etG9|OWKOo^X%>uT`n_4uaL{8e#RDUAM>7H~ z`xCg54g_3blb3hzY16Up?O}t?!@lV~Ru6X}od*!DN;mLC)iRwk?|>hlu0_eRIsZbO zpI`G$)&WV79jsIrL;$H$Nm>C@TYwY4KpvK)Q~_EyZ1O`2j8j~3sk5YrgzY~rOO0GA zT@89R-ki-#9ot(wz5Am`ps|20$+X_ytud0SWU!HSBc(7nq-{Rc8;3(1Zd*0G{yvqz z1Tq1sSpa#J9&nkFahR1f9hA|ApOsZ`KQwH;=;B_?R*)i?jb>6G{jwkk-|w>P;ysMu z$hrHWrbS(1|`00nAoVN1Ue0j zu&nPGo@|b-h4%)@!~56a+Uq(MZ`t>pXohW3pYwK75F5e}2(wl#fJPY@u5yXHZ(fJ+ zJOY28=bD32*2Y`n*nFO^YQi`l!lO^%b|S~j(xr?~iO>O#1!7JnZjV5eUc~^p?zE*l zj(3s9m8u(PzCn0n+rIKO8t`^HYy9DprO0MN<2nIm>RhP@1t#;AufzriK zK`c(-O@qD*JQHc}Yh~24B%h|Je7zbY^zzR~oi1%0$mxJDYS!P7_Zw5X!SzrK-u&=} zV5)_vM`SQUeTL5)%!yMk$5U=HP3Qxwr}M4?V8GgjfDB+1SebrwLklT8{!a@D&VxY~ z9nwYc-173-++QLG5FLb|x|hFa-v!iO+ns?Bj$XlpFbu8NB8}M2@ugci@F<0a-#}2g z?bHM3w|8@dB}i?+{&P!lPB)K4Pr~hxWg~!M6)693mc4DmqnAJ{@;Cd*kmDHJjl7ix zZ6y5>u^LbVT?#-aVul0Yx;r^o6PSKg5Z8rPTbtDa=G`-&BTBY?eD@NdAyIAF6@j_J zSS-lDoQ)J7#;vTF7U%-h1*_lO7h4zjb(3Hd5B(Zsvv?1PhSpOa##|Hdoaox~I@f=F z<>F7!bQOe@z=OZyw$_iN3T zjmH>T`3_^h&A=YGuGXc556py033t5)5TFau-q7z{(E%3M;zeNCMu27jWCK8gWOW50 z+3Z6E<6Y$JLoyG-0Ws;8JC9W0pX89iok=Yk8_RvCKNY%ug(Sw6ugQK6$c9Sdr&a(o zdqn!R_S5v;JK+1?Tr~oz`f$n)$Rco0;*DpNj+s@G()7t`>8lI_U) zvR1xQi=$K_l!tqBH-Vt*hzjggKZnYNoO(POY6Mrrlwsu~$E#Rka607Kh}#h)CrkMZ zypZhM$^=5zZjH#jK(>AE`{eNiw!|u)o|Iv88FwYnNF)}Uq$xO?xt%Ok#bv|fJ9ig?un1U_Q{6^Hlifw1AST3 zW{58N>N|u!g69r1Fiamaq#}BN{$yx_Go*qb^GuIg`&oFeffxCM`071B%rA6YAI#+Q z(L_C>nWENaCkPHoq1M)Pc%(?Cniqt+e=hyv*fO#$Xs!~>-0ojnRGxVzfM{85V6Jb{ zzm;-nT+$=RVF4SqBrw!*u#LQm>hz5~51a&`L>%je`J-BIOt zDdq%q@3}fR(#g6`=2D>G>II_cRBlHh`YtFT@N1e0(^Egk_U~O|mEb7VD}J>Iw_uZy1^U_;laxi-`Ii7!Ih4L8Eiv z;zJbdXg6f=WRFdk5$dr}&Gx+HaK!Q>Rjb>^ZJ@de9208?PI=Qt!2hg%=| z^bTu)=2uRm^59j>e^{#JyYn?yNaxIZ(=6?3Q_qDIkK2GQYrq)H9vl^=)K%Bak z4^Tq4q=o;+fuR*V#uU{V*JF&t0aQC4Q|bH4v;u~Mdv$iW;G?9x?w`wS!x!)JM%Yn^ zhg(q>T{FR2qsrgGo{*Hon?Gbmey?%}?vBM$=vchU^|&tpO%5(mA`4qY$WzONhnOupV2`1CPiwU!*($ykf zNW5;0v|$lGyv0S_s~Ezp;jT++iH9Lj)38i}v5-;xN&20;C^>(Lbr6c62qC)V`rQzd ztPDEE4B$B^NW_L$Jp@+n=0FT8jOmLdOk2F1XvG5|4@-L*4I4~;Xh5=CHf{E>2!%PrJszX^G`@xCv zGQbz1)c2|X-w8~k(;3HSYU`li3G)zRl9^x#gJH{J9zt?z@;#V>G+(mGd1(MVPl!F} zxG*m|t35?=kV;WTJqC#(0tfcscKbc5wdzXVyeX@Sqk|dPq3Vh9X(7aTO`$Zhy}{}9J|AIjMS*gYU0 z=i4#{f3K(n?+wL9=(^jU>6-M-Om6H}s)hAQH!kI1OoI;ogSp{!&21g{1muF_<$7iO z3iGF105D&lx3ZYWyta%|&k8730k<3QcI16nADpV;aOhr}=~9h>5WM2RzICenFW|^W z2FwF;G#G2ZOL1G~Zz7%`H+U&3p6TYvtWv|3@T`r&%d-NorMe_lfN~iPri0cXdRVc! z4OA#?jM@UMudp|X6$ir&UxMZ>8B{Wp`GQ0<#kr@dPcSO}7bbzx@3NDA zLi&q;eExIo|2tv*NmPVO`Umb#Ipu7$|4kVHpIUMVE2elcS8Xy+ zh}0z!pMjIM3QF$#DcPz@3E|(gbWw!5}sA4H!#c)Rwn$xi3La?~(8$=H6fg zE+zwLBTtV|l@{EV@5CrTxNH(%h=wYJ>JqGO>%oNf925_hc|I{2IN;7Djz`cSYk~7X zWkn@Gtt=kC1hO5=r7f!unbX!ZKXHkV?_gTaI8*>LJKO(gs9%WjoEs)5Qvm4JQggB5 zj;Z2Sl9=v>VEGhdspGS4m2}?o zbI=o+J_0*P!*LKVDe~Mr5b%E9R-HAEEr$#7GgMC^NcHM9G4R)QYf5tbxnAkI#Dqe4 zAPJ)u!Fm{!AisUj81tvKPz;8K`#ezPRA38=nMm0Dw?u!G`+)$J-?sw(V&<&GPSEBX zj(h$6sekblAb?(<;9zxjxC$`Lz=5b&C#v9)^J{lu)X(Ivzv{?wKub2~Qg1~%vOM}#*`!tFa=;J$_3Vm zM3tAQZzzyZ@w$LGB^z8jHQ(C74q4y{pwXIs7zItf2qztQia*K!_D_4y6pgRfPv8Yz z;(!c}T8I(!fRpX;7j-@Gg|K3vdFG1E8Gt%^u`dBC$|M;D1d$Z4dg^kaI)0|Ge?^KE}+nqf)kD5PE9v^mecU-!H~vN62uWKp%-UI zz?fe_2=EHNLPo(4BX5?^{!s{+l`Wj&k3RV6zXmFYmbh&2%uLRnV3w4lu6l{ro%LVK zI4|A*>fbdU@*Yetr_JH{Z_qL}oQ}S@u3b}jxjvWVy$9sZSuul{eg@+MMS=qGD|D;y zI!ML>A_de{aY%59ff!b>v9$!uj;VN49KCd#GAu<0XdSgNA17twxSBwm2G5lrSn9JY zuxaBY@jxobOB8X#jEO7>{_F#+vL##Hi#x!&bBq!=e*B#hBH{*X#?sY5Dg+}rhYcC$ z04B(E;!V%=H93q9Tg5A8&@m?D&VDZNS^!vAHYfx|gcG0_QqvY)#RBPNo{iVs63I}- zAMy9my(nOGa*YMAL5O-CYNTJ?nOFi3D|Ys=<@#Zfn1_9LdfNDW-{D7EXV1hGb7#96 z0HeaCRb6txQKrNJ^PC~o7=UNq(%pt7Oafx%VetbfWhrt<6UVOg{Lz)84^kXaIT-711pW z`RtVu#%9%@ejp(Fc&9)`myZe7?12mhMm2p9PEZFnJ? zM@1~Q*Dou$#tCsAJUz@F{fs%kNBIXBJXf(6bAFvy4_pDy<}~C00NkbEBhFUibYJT2 zVq$6pw#HVRpx|q9Kz5V|v=K)ggOQ6ZkzqQ3q+^gKV z!K?uUN8ZH9iRwq2zg7XNx=ju5%Mch!i**MuBgWjhL?_P_W6cY2$=#J&;}Mr1E#FE% zz5+ltst02 z=$}pI`}*Rb;fgK*zA^}~-?kznoWW>sld&eg*0tN?iD&>9n(7>`0;SJr9MQXWoA1U@)cy;SPr$6s3lO;!z;n_7 z9CodvJla%6eyEhkN{nHJo{|n&%KS$QjPmde|Nb7#a!s7#7~bA5(vQs$ak;at2Bib? zMA;2fIxk}{u(`eiTGdgBtkLgmo?Vel<3C_V})VQ z6HNu;I)U*}7#k`vM*iBox&B^fI?&e5%RCJ)#u%>ZmuY4R3u&Y|Mly^L{3sy>Ga zz3(Dn=M5Qu1(HYWE-?m>q_Z^!-_u+9@df2D1OUZb6MM@r?_76YM#*(RsQetjg!aG`Ak;hLO-hIg@pzSq0VZ7+*gfQuV1r?B z>+>tAvEQIQYf{w)i-$3tTW+()YT5v=r1UG_@)IJJ!jsUzq2THKRHH(ezWF|)Ku}!(~18KbvR!EiY6HQZ$p}<2|%-`O^hLi3s_}?Agve3 z8wR|AKMGX{o#gB73Ks(jDdbyN2PSiQUjPx*-{!u-l4f1O=3V7Pl9QP>hvNSArs8k@=VO* zh2RBnSxq?9JbmLIoDOh^wjGX_prHVE2-KK@Gru_l_PE~cwqROKz2$U&R3bE56oW!= zW8n}z4)MNWR~)*?;aa6@{q!@n86W4dDA~x?E1`F0OB6TB#V~W&bVx%hw-pDbG3pzS zTpoA{ZVwQLJ#B>7ji=HXLHa_9q1Rlb{kVt(_dkS{Jld_{CHI2({{U8Q0BK`@9*huo z()$nA74gD%>~MZ89}r|mD;@}}z^aD<52>)I3@{>&$6*m#$I9!m1vikBh8t3cn?*#(t@nOMn&z-vfjR$B}SVl&wqA@kmI6*4* z5m?27*E51B3WM7}J>K01?P}zzvCB-O_F2$kH$G-*4Roi{g3`5P+z@7LF0FlV{RjhM zN;232Q$I*1;?_u3G?TcGW^`kbdts=i4Il=e=LDg=s@ec#+#5Fg*8<3L3+$?)>vf^+ zna{sb(E)iyZQpv9&D{zf#{Y2vcy(0&yH?@RAqqKdYkmulgfMKUYG1)?Q;tI(3@@Q( z@;%VuK4M4Df$DoFB|3QnEP3m9IzNkn3RM7A*q-2rO3&b#*NV4ZZYIDKa9YT#x z8VtrCmXZPPub7|sQ2M*)3{(i3W(5&z5mk7Nl5d z+^|3q`{nIuq!UBjPzKE6*N4*7kDPKO zTz?HM3aa27@_?}%)B2}`j1d_(hvIc%yDjYQ1ASoYXN)OS#vKYO)MI_(b0A~b1362> zz#gC!7>*2soY+Mztau!ZT!V}eF$mH)c`%t+Qp>e!^UF{%Mjf`>exL+{*TnbaM|+?@ ztvEo5MLxCzHYiPZmQ+Ap{Ad*z?Qo>>fQJ$!WXb}Yf3ImfLP8Wq0 z0NKahJ<~l@dPWf^@>&ULWhePt)haJ$VW3tMpr;CgU2Q*~D}SgE)Fb_D(M02nNunGC zT3n31Sti(Q-NoFXpg5-i2DK*0b-}CDqvq2&|GbN_y96cqu~!ICz9Sbk2glvPtN~)% zbn&crF+5)||0M&wU~Ksk{0{eeo^t-e>Bp0fq)O?30$QtV4Ce>jkHwO?Q;$c}Z=`)2 z{azi^pwJOhWKD$pgAygn7vUl?0B6W`pv#Q{qxl4h7{$8YhQJ+4;URe4-sR-{jm8t? z33;0i984gB6fnoXpljg3gjkij)_Z&+@)K}CJUROpV0B+oeTe(?!){buwg>nIf)nGn#>0n`#;=5;tG@7?mY-2sJ9_I6?2 zq_qmh%YQy#KM`|}!E9nj0f7rFJwO7sj3(7e(+?1Oiy_JAa=#_SgQyX3+-k5J;>57( z6W1(*PWo(BK{uQM?vuO!wdH`0PlX`}4Gf%_)&eYF|6KKN%#?&-plc8D0IDt&&%Ge! z4uv%pIj*frCtep5*uh!FmdnCiy4P*MCB#W|>LD+#V65wmx{e0T!rp)OK@e9sSJ-*IQ zPb*%+&;HHKfIPwbFunCoWgfh(ZiEhhui1uZ80Kv)pOm~^F$wNhwQ(SMV3d3Dma)m0 z5Yu4tV?So7eGnz?1%ya)WnsKqg#&gFF%G!0%jTMc4IqlV1*9_t&5WrY;yy)=^-uYe zFlZEy7E&+SofAyC4xILnEFeVBMbmbtgNcB0!BpHGYX$S}~Z0Vlr*;^ZZol*c?gn1W{d+14W z?wAgkFvv^--uTtl{SLSAv>=PXOPjNOas6x;(gfDqBr?EqUlw>Lw4t-b@2O~g)qtfF z!>XQuABrcLaE&&fV7NWQk9^GM6Vji$mj=bZHi(If@s9!ryE%@dg-cbt|3Dgi!1%ks z0M|=R(z3=r30F7zE)0+O&pvZl_^|N5oS2AP*t(Mbzj3-ijqq8#qX255MyKU%IS?@`kvg?P>Cy`^+DYB3p)5^l!XyY zGx7GJ%7a921>u|J#@@%ka)SpUIW0gA#fky zZ}fpYy9E#)45hwdefu3a;KUKkU<%6u3X&Hukh-e?BUz=6fSsyKsgv(H;1WA6lVOj6 z=HGmaY6-H$6N7yAr39smbps9ny=q#n-#nZsAiAd^hNTO(vZZfB9B^V9IJTcA8+rhi`BMz?nv)E|Jc0#YA<*F$MoDGcx$u4%q@4z z|2yUIqjYDW0zGL#R&08>!n$sG*h%KKxk?|){qUU^2Z;Tx+*gXv|0b(Y5VsBJ7B@FfV}h(RKU{%+ zaJtSzMJ0SXV1HDoF=0LEbSytA=`Le~h0nBolsmOgFO~Y+N;izSlF?9C-?-=f$3~FX zJ=NGRm8y*l7#Pg^?c2O&>GtVq^F-P(4_-tF1cIa7$&u{bAnB&T<0>ZSb}SuCCt~3u z1&ND8*a7shXYGdTiGuTk@RON&x)Kpav9cOQnYR+vYMGWiiXq+s%3h-HXF;A?d{AdI zp4xpnkU+l~JoBQ16{J+H{qwrIzO)r20HVRdp{j)rM_G|5odz4v#8}1l6P+xu8vT7q z9Or({3LKb`i6sU<8HDD;V$j?OgLc@pHtUV5wRdGHN05=;y7$eK2J=GOG%IppPRsR?V)^s~d`} z@iG?`~xM@{ks|`ryZbDFn>)N)sH64@kws8Vrgl5#S+M6zk0f8rt^f^HLFm{joX--nK@tXWp(uG;0w$UQ2!oI zK>}V}N_CL(c@x|t{q6%(yr}@SQsVpU5nayJ&HYs%rn}TzxtTaLh6?7wvqLrK{P##O z4m@W&)q2rNY6w;@lZJK#oy$lVw84W*1^&^iguVY}kb>^9ngoPsU@B9T!k&!UAt9Q< za{yEem~-dC`=Mm? zz(GSjNQ?X5L4zfR3~FV3V=xZ3xj=dUD!2fT@^+s@vhiain(L(?LU#K&=(Jv){n4ic zwjX?xuBYt@R8~RpOA3cSi(i%;1Dak-*rk%Saf-L)L~{sgTfr*0x<~e z1;ys$B=e!giBX9e^f_=*(rU$3UjT27P@F%fSS;EwJu1Vo$o`&#jcwnMoX0PgB~5>H z3hMiP;wg_2A@Y?Q>gO0fn^9&6O>fytF5(kAUdwC*x(2+%6A)@ZIdVI64&KM-#AQ|g z%+JNj&|6thSb(?d^uv2nJGhS*k)~tA6~(y9$?J$jq;LsqqY!;tZ^v{F4-ZpONE)aN z0Rlo|%8=@pTnnGtNm}XMNkSXOTy>2~c6=zo6;AgCq@7RSwP4~(9v;l`?ZePTVjz3% z`%8QUg0_27f5IhrIK=u!&p~(aBB}E&kjA`yExT2#B9j30mKRG8BI5R=9g*Zu7$#hJ z_syGp9=KaobjGSo zOTq8t08=|7lf8l9%VYH7kd5dOBx;&52XfmNY@X_ujruLy1?Wyrm}#t+ZxH>Qe)T8? z}h=7)C#q3l;xnY30ab%4B)RQ z8@kiSpbh7YUU?y)ab7IH*5HhqV-Qr(URr*-Z)v3$C==W`*c09+y5U4D4t*q&^x~*k z-1mR5fzO7+T+o?oPZL&G8oA``=b#A_$GqGF?1xyP)Q88?sw1a|U=t+xkV9*h2e|;i z=ie4LS%^8Ux<*2v-SS&>W-cl2&*h_SVjnB^3LbM3l}Lq~DN2h*xQC_S0FM&KIxE*8 zz@Bh;^KPQv`F8}=izUUys`mYzp!6${^;thj$%yFPV}L9ZlcX)M2XQ`DkKOC8j?Imc9+zAAsxTlfVIw6jW1GH0yoH$u`)#AI>SCtJl*-;>vh@C+H6+ z!A{hn!l87M?GyS%%i<(!=3K1MV9nt0EmI6Uq@^gzcmhR-CqHME3m~ zB>q|2jAFOs_Xe@G|AYeAY@l z-2H(G&a>Q+qBL>AZnxK|Z8)EI6Z091^EkFE8#177X#70Pqek7Br+b8&%=mTUz4(H* z|1Dw$r7869mA6O)Cs;QaYdc>`7+&fwpf2 z%G*l#-*b1oT7s!)bvUA$&$Lkas3vT(DSY$`c?4d$=b`{bK41J(Z+>^ zw@}+%k*b=S_vXX{eSbP^FLDotfjN#e*YAKqtv{Rr)CNe;b|#+91EL4#+rAwmrWA+R zygIoIOlq4^%x>@s_hx>BE>&}G5zbKQaLTSJxtu!3KVTK@TUAH1xLwa9)|Kw_OU)&> zaWVh_E&Lhux0t;&ZBNqcv0l=xl{Aze420E9{KZ{=%3CD1aNs zLh5pxcm6=}IUSSG>683|H`giv zPd`-GP@Z;6BSO-SON>f*ifR-%GP~*CjbMy;+MmY&HIroHek)qyO=cqjEnm0+QIHE3 zymw5bUNBnbMO-Q2vh+Ux4A@b!QJ@>DVM48oEh33&X^oR>u z#w`5ic^f@3G8?!6M1c)o=(H z{G@OxD$6>pjX8-t>g<7U<)otP90tpv&cA`xK|P^Mm%VKTc-WWHDd}ZMF;V?Fv+fv-vC=hr>$Ue>-Fz?oCF(Da1w)T6^>B*D3 zXZ`ap4$YtQNA8%P<{E8$7hjzpV3$Pt&Y8Foko|lTMY)qHtjp1!)$?vE?b%~wGm|F4 z3B$hn#Kzgtj%95EWIupfn{3k<-r>ws-2S5& zz9o{Nth{Y4c)zxA`D(OdAnV2I->bWPdFhu-!7!hbCx!tgf(U3tB`l||?Gk*o9IgGT z(aDdz%9K>9lORD;Ak`ezb*$PQf#K^OR76GJj!!*^>!Q!h6^&vG`>6^C9BLOB8>HH0k)FoIABHxb_?~8qQm^>sqa!$Z%GGC`k`|Cz zZ@;TJ*pLlRrF~9w)9O2pt7|h5BX1;@cqkRS6m7l6Hux(-*lVvz9<>3czxNjqRS|mX zYxrF-sun9W>dBhyNnwDc!v5QRfUzQ)lbYk*u;th z4ML#sp9VzU3I;p9MotOwTepy4$ek!#&9_*BLfqlzhxnVE|;dl;mRQe=xlO zf6jlzl?f=(;WI8{V_MmN!5}?q@35TMnj<+k)yeX#Q_yonXn*YF)~x`A@LS$%sQjE* zsKUMAtK9)+6o<-_-#ccm;yu71fck77DH=Gs-CFbHqudc`PJ093@*_z_SX`2%Y~JOP zC(9B0c4j_@_~W$Kq56XGqB)SSsj^FvlVD>wXA-#fjEFF2D&1~-<>q|t+>o`k0yVxT z+bIvlX#}f)wZ*`Bw^||0_AnC3UaYb3sc`zKwOqy@YnH##0JRZv>dU9!3vADMBYm?B zM?^yaRT+F`nN!n#Ixg@E9{edw^XHSx!W6l z{LMM*^JJGG(M##=rsb{4Dxn@HA|_9%bdklUc%y35HZ=u8YE>@gNafwQ)6;q*2Tn3* zxMzZryT1xt8j+^^VDDxZmA@1*Mu0t2!-9%clHg{TXAm4KfGfB}2a^vg_KNjB{+z6R zKAHtTv3Mx2ke+*Q|Fa6^JHB7i;>&jHEX46jffCbV5~*+oe=Q$){`hkK@w%)6dKU2E z=N!!J!_j?D(4}aavh;C_tDZrvTo+$=K7G}NNQeW!O5M;3@9w4eC9hmCp>xSp*e-u@ zP>5`*MWsZt%u@08C}k)}NRid9RVj_7_do{N1XXSba6p7`{x&;?s+z}hk^1haj7%-y+(lDNCAHB8By0D`E=BL(dp@VS#}GiL1#_h<^o z-A*XbMU$U4D|7U^CICO&Ds0uvaFM%4ufa}JRgl$SMGl#Am;04aOyv(bu`=1$@1c-CSvT@Yv0kP z{!crS9UIQuEzZ9`#4V(uU?L~nTp;nDc!%>Cqp z^~#_@T29rs-hUF?$S9nTHYY{MaXsyb?oKC!^S!*ir}5%KfdLzr(-2wLvB+t*VxZpQ zz=Ly_M~rcwt6|cjwXC?TLg)H(a90d@=QZIJ$O#ShTr}+oU~z~-O`@G0j2iwC73B~+ ziT2L>RQ0YtC}Hn*N|oLjS#_EY$hfH`3}JoNiJfV;lW_2;NLMM<#=J*9Js!SLt@Mt{ zh-)H`ksiON-red5w9AE$3?fOX8g+6gQL*O>#frvDe_o5>J)K=l`St6Ge=Y47=6OmD zRhBp&8EgS9)q+2h1B_SjOxr+aVUm=lT%{Ec&CGLh8_CVhcBP+TQ+=lh_PzaYm7O$o z^k1Kfe1mf|we^#Tjd9Po@;Hp_Nx;OSRK(bR~;NeY90xsqo zU3$Le;Mx`R!7Qzxzn?L;E3>-r?!5wDO$sQ_9B=E7tp@5Y-$k+aga-LnyI!7cB;I$0 zt^R;`Sw}+_@FTPFBJL)vv!z0FDi9o>8Hp4xBi{BBC=i^4gmUZ{n|NSepI+k%IqTJK zJzHw^j)&WJyn`V@492`Sw6+;M3(uiGOg)SEDS-{65)B=D+k78=s`SpYR$7EOlRnGF!0hUjEMp1X@k65FMF}6GnpL4JTFuA zoG*>Na1rTv47KGg$UV2Gz#`bjL!|kY{1)wC>&j~Z4;AssqZJOj%MdQ3r72EFi@K4| zXEAv_ghwdya3#OA5wI(h_T0XenD_{t%9F#(xn=ZKVr+Swz~rSvF3T;7Uc{WsO*?O6 z>G~8@D7?tUA~{LxQfnCmW9 z5w;16enR7Ea)^T*YrbGo_>Q0BS@F{f{Vg|p2M}hS8ud=y?2-!DrYOf-kE}BILRn9B z3OrY462I0yx32an-Fe8F{PuD(#hX&*r;O*t!NY$9u3xHHfJM6J`|SY~%Iy9~#iTk) z+hV?#8g&PyUk4LYS|Da?KmV~4lx6YBE2Q|mV=dd+Vc}Eu4Z^@W%kw4rH2?(P3x25> zYxZgN<<^fgRnE)))7>Yp-#8dM``8j^Vc~He&8@zk^CneV0^dE#l3@~(W=e4 zqV2nZYdgv4MPsS`$)X?gFm|OD8&?GeS`uaYWtilhb$Za2*kPlP@Vj#dUWZ~nxpyy@r?_T{9hmiKp%a=f1{xN+i)@pxFnuvB%3l4}zmn%L9InylN4 zKygax@GDyX#|2Og!RMOGfsnvH)$i^oTV{NvT&0!6(N9Jd{rzWl&OV_=Z>2YkivdFRp(SE}8d2E$}l0d{yjG4FTDo?@@2{RM;z32)VkN&Wxi4BS;Rax?*h7!ek zx?yLc-|}t=7ebA_`t_tdmW_hSuiR7rxP9GxS>7|a0fFZsZlt+vuLs8c;y6(9lTt>X zaF8@5?Rvq@>vSFVN<((Zjc(ed)-7OT|FQj=;2zT%85L3+VJcud@_c`-ls2a+DV!pl zfV|#&;g`VhdX=#gfCOk!mB!)~UjJT9upuHr%l6rS)>|l-Rq1V{s?1q*o^O=iL)Y62 zafkPT}PZDJUY2ELV%3fSBhg zq>SOG*q-WxCg?&bvZdEbyK;E9yW6eYMEdjTQ|Y2}0>fUhhhk?Cj&s^YawHof4DJuM zM1G5^2H1T83xwz&heo5avC)mSTQ>*(TE>SWp#Q`{+yh4F+5{@v$1_7nl(|?K@_>ho zoXarBFBsKO+tnjOp?N}zJc7Fq5;7P_kLh?&H<0qqZAuv$Qp5@R0iL&M)5{?ZL`)mc zDEB1{Z%D&(wCyV^?8?TZ^?yI7S31MIBdL#anY#5fl#9ma{fHhQaukGJa49nkl47#a zE6|Lv9N~}U3cx3BgE*>{R}%p}`5eRevtc7*#l<+>K`ES^Md#Jg&b)%H0l(Joe+=!) z4o^P?g44(=QSDm0$VMlUhMZ2K-59So4?M|2n1ExbT9>OzFZEp;xi@eAvs5- zm&U#JTATg#Nq9ZLo-nvN^^dT%dAM}sS2zMXf5vAf1?lXG}-<9_F4 zBwRhyCV_-^dg-}}d~`tX9WuU=i`}<1MJu&`C|MQPiq0|SVC3ux_6C1c$>|WHcaCkk zhXGiv-tKXN$#0X8m$@^g(0z5Xrp#Q-v^i*$Tc)H<6J`XbA@j!n5;r8x4AqKDvto+p zEzDdg#-Qh5ErW{_VSgBV@0`Uj_37)}2sJ%#f z3uDG8VTNwuoE=Ey)EH6fm#HZmVQRw(fx`pqAO|k7=4MEV^qQl~V6)2fmH~Wz>4;74 z#grU*_gkxW)3ki{eIa^f79&TBva@~VBgreaL0v#8sSEbl?>2W;n|I`dA<|4EFwYNr zfj4X2W_}~z%(q&)ZjNhNRgqT-fB4~HSF{UDH2kXDFhwuPYsc@3&9XVaL**+-BGW&m zJ|>2|AbP(RZ8H)T&n0$p8&ZAn?drl`zX_}W^AM@=spyp=r`3N37LIg=hAkVO-Xx!R zXOgqtzA&Y0SEl?$Wa&U7x;{2eNsr@$3PWM+ad^b5&Z#O`dgSY=3NVqUGw~UlAG5L( z_%ekN0s@K-x2m?o9y?u(Owdp2fL#?6c!>HQntTOy3~Oe3dp{NReOci$Dg|{cyUT^j z-G4TAfxSsl-%2EtgxyS` zZXDzX!wqUvN)93Qu5N> znv{e`48Jslk(7%aT}gfEOGx{tvapcjjSda(J(dad`L5Sg(OU&k-}3PDNSFR?>%!Mv za~XNKw1XV72m?J$hK}JItiV2_nsuICAU?{mp|~R6cM`&3~5> z5sKXqD{@f{(A>5No~UWNvwM{hLAL3)S zFKOFpN5vVPq@pw;pqE`LiD=e(R*9R32TBE?XrEuOY`Y8eMx-t)5?R~iDye#07)W>% zQbb_ZdWUp}*2uAwedSZ_wb6FbfwfMuOrakV%t`IHIK?Wdu3Nyka4f1&DY8=_K(-rQ zIAP6~G`n;j$(SDqLDO*a?*?4~M^BZJg_@VGqLzULp(s&eXA;O*hbJ-DPgwJg%V6}8lKRL?hbgEeoepL~0$i7@a#AD9GVtvZ5m;_UWH;6e@X)4loo`o6_4Bt@kS@Z;AG6R_Mv>_k1lRIRJQj4&W0_n7jAU zW(s~hI$^N2=pWYE_}USzLa2q%=h)jPJxvv255BGaxDoi7{5R z^Um^3bN)rz=j474ds*8y(s#P_jaeP>==50$XyiKocSvkr9U$FtIVJhv!k!;S^x2IqB%P) zsF@zr;@?9Ms<=wHgeQI8tn#E@N|q$bHR~$V_OgBY;II~6w#tm&aVUbSDh<_ffcQ5) z3lhjI{1QZNy)5l%JZ4UUTXPgix1RobJ;od`C^S~sJ38!&E9G|FKgurQs{hd%wm|ky zRKm<;Hkmk784|*xq`{+*&bye*0ErZ0qfKqBrp1YbCHML=mS;+{Dgdkbo>-E3zcMM> zux4mMGC^MNMI^n&;ULP6w@Ft zgWyr#>K=u`TT>ul(Z26i2m=FRtU|vSjX}hfJZxREZ@O56{sHb?1VcFitKc>N`4Mgb{0tPG5R?6j zBMZi_LSzd0=(iV#ZKF`#s`+2_u*JvI_1fC+-M(#XCf(k%_55bE|Fz@OtF{yq&(ZOr zW1&W*wMCFL?H%DK2HTh}8U$kRjnKkaUk?%Ym$7P)lBQE?wJ_Ffu-h$H9!s**Vt&^fh z-J9(ooX4LeZ!{fVTnNh;;nn`kqQp0I*N5-kTAB%Rys)CTABU8}`i{yHo9~Ocj;K*S zot_4hWwHZyHmd}ke}E*Jafp*LF|scj*o*XxzMHggzAFX7h41$V@*|WU6Q#EO$&3D8 zhmDd_BpIPoNmXTi5P=i=H?5`)B`q> z$^-?+>=9_nDA~u9WaY9?2_I$Zt1Vty095V&LDPSP~O@3oc*6lvCAawbj&L%u5yf@$Z3m5@cpo>G2 z8j4)HYfSu%FkdYT<8(V-g%UQq8#=Sc94U;xMcIlMfDQ4>LgR!O``_I~3<0(wUypKc zWa-Cj7&njl0}s41^O8-MS1tJdF-!beD#pZODX5~#&8G71sYc6=AScO36;IS1T0rRY zZ){R}2Wi17kJ^M%Ua=n@Bc=`=7wu_2wwLF;V;xt+&jgc^FtOEouvJ!P5uGvj)Yc?s z^}o6xA8py3C=H%}@|tlilKy@KPZf}Wi@C!`t%GBatjbw VwD_a&{}~dgEQtN6!#OM8@2Y<_>3xr1T22 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

g)l6V!P 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 ho9+M4E{~AP+Yd7;v{9((C-Hf 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 literal 0 HcmV?d00001 diff --git a/hrp/docs/assets/qrcode.jpg b/hrp/docs/assets/qrcode.jpg new file mode 100644 index 0000000000000000000000000000000000000000..386a237d675e9ad5bde188a1d9b991de7cabcda9 GIT binary patch literal 8706 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 !^* zxFdvnOGxPueNF_!*ltYn(WFC&i!3Vsby#t2%{IGReS~v1v3|4K{k9rs7PpKNDMp64 z85r(l?@nd1M>VG`_|uOBsIxJBXx4&39n+lJF!x2%?2;~PT6ao%smnwdyC;SANt13m z%bzbmq@SGLtgzuif}F~wPHi-G4fpgEpiis7!XBf)r0{RnJpMJcBD%j2H`PIFyKg~? z<5oS9)9Ql>u=3WpY1p%Ji!s@|0`!Cm6JWjoeR)lLWeBGb^Os@TaDgOSFqKLBY&Asd zo_ogIc&6E7ZU;$USu<*q8$d{+iGK=iwMiXw~v?dNQ33%{% zR0@AHt}NSEO9^A}Qs=Vdw!eV$FjAwLAc@jyrfS_$HBiP3YZM&7ocTv-S12&kLINVb?5 zmDNHTOHUi9^@u+)r{G$k6UtIe4TkFAr>Y_W^QhbN3mDI;WQb=-R5L}nei~b0DzODI zMhb}1|nDuaJn5aKa4}*EHD(p|maI4s;tsTqM@asY2|$&5^~vzz+o|p$5yc3~g+? z#7X14akz}WwE;}mARSQ5@CO-Vz+xttQW(yFPR)7c+m47M;tV50I7(oDzyz|_bmG;MrOLfsq-+pA)Di1+QCk~cNE)h z(KjOPl85xA#j*#!L3Okt3))?qJd(UM8D->=m^(%(NRhV*pK?H=XDnN2&`RhTMw?Ga zV5nOmW5n(KPJSOA0jPKhnKmJHe(jORNKt%Tb~qR#9Nq|c04`&0zbRvJED+Ob;U43* z895Um$%~H$&>crsvATEtY+$(qBg1oge;~sh$GNvX#+@)&VYbC*EjBhP7ZP{ag2P|P zm3O|`eMp+72k{QHKMh;toDYeafGbz}#sZRLV5`ISF<>oQKAwU?m?9S$WL!4gkxVoO z8HF2N6&U(!;XViOE706GnFN@3At{7p%R@j)2xC#zC(r1R$q$5RMn2L z{tisef4q)^gcS|De&6>Au;2eMPp;my2h799Mqo?^vL}~g+2cB94?cDY+Y}LILqH^! zs4f7BS=qLwB*IsZ*3V8pUB4-vj? z2Vvwn3zOpz`b#6o1Idc`1ZH4wp8a@ zuzQR~I3>`p8sm$<@38@w4^*HS0K&-%txkInoHk@cfJb3C#&pp1fS^vFBfl-ekvJD( zRtJ)|aEDVW9vE?W1%_R^bRmx6MaJn{k_w}v>k&zT}*i@PE7C#?1(LTLnbf9y=YQ#1b?erpKc{vXS)CD8x? literal 0 HcmV?d00001 diff --git a/hrp/docs/assets/sentry-logo-black.svg b/hrp/docs/assets/sentry-logo-black.svg new file mode 100644 index 00000000..59b79bc5 --- /dev/null +++ b/hrp/docs/assets/sentry-logo-black.svg @@ -0,0 +1 @@ +sentry-logo-black \ No newline at end of file diff --git a/hrp/docs/builtin.md b/hrp/docs/builtin.md new file mode 100644 index 00000000..899e822c --- /dev/null +++ b/hrp/docs/builtin.md @@ -0,0 +1,53 @@ +# Builtin + +## Builtin assertions + +HttpRunner+ validation should follow the following format. `check`, `assert` and `expect` are required field. + +```json +{ + "check": "status_code", // target field, usually used with jmespath + "assert": "equals", // assertion method, you can use builtin method or custom defined function + "expect": 200, // expected value + "msg": "check response status code" // optional, print this message if assertion failed +} +``` + +The `assert` method name will be mapped to a built-in function with the following function signature. + +```go +func(t assert.TestingT, actual interface{}, expected interface{}, msgAndArgs ...interface{}) bool +``` + +Currently, HttpRunner+ has the following built-in assertion functions. + +| `assert` | Description | A(check), B(expect) | examples | +| --- | --- | --- | --- | +| `eq`, `equals`, `equal` | value is equal | A == B | 9 eq 9 | +| `lt`, `less_than` | less than | A < B | 7 lt 8 | +| `le`, `less_or_equals` | less than or equals | A <= B | 7 le 8, 8 le 8 | +| `gt`, `greater_than` | greater than | A > B | 8 gt 7 | +| `ge`, `greater_or_equals` | greater than or equals | A >= B | 8 ge 7, 8 ge 8 | +| `ne`, `not_equal` | not equals | A != B | 6 ne 9 | +| `str_eq`, `string_equals` | string equals | str(A) == str(B) | 123 str_eq '123' | +| `len_eq`, `length_equals`, `length_equal` | length equals | len(A) == B | 'abc' len_eq 3, [1,2] len_eq 2 | +| `len_gt`, `count_gt`, `length_greater_than` | length greater than | len(A) > B | 'abc' len_gt 2, [1,2,3] len_gt 2 | +| `len_ge`, `count_ge`, `length_greater_or_equals` | length greater than or equals | len(A) >= B | 'abc' len_ge 3, [1,2,3] len_gt 3 | +| `len_lt`, `count_lt`, `length_less_than` | length less than | len(A) < B | 'abc' len_lt 4, [1,2,3] len_lt 4 | +| `len_le`, `count_le`, `length_less_or_equals` | length less than or equals | len(A) <= B | 'abc' len_le 3, [1,2,3] len_le 3 | +| `contains` | contains | [1, 2] contains 1 | 'abc' contains 'a', [1,2,3] len_lt 4 | +| `contained_by` | contained by | A in B | 'a' contained_by 'abc', 1 contained_by [1,2] | +| `type_match` | A and B are in the same type | type(A) == type(B) | 123 type_match 1 | +| `regex_match` | regex matches | re.match(B, A) | 'abcdef' regex_match 'a\w+d' | +| `startswith` | starts with | A.startswith(B) is True | 'abc' startswith 'ab' | +| `endswith` | ends with | A.endswith(B) is True | 'abc' endswith 'bc' | + +## Builtin functions + +| Name | Arguments | Description | +| --- | --- | --- | +| `get_timestamp` | () | get the thirteen-digit timestamp of current time. | +| `sleep` | (n int) | sleep n seconds to simulate the thinking time. | +| `gen_random_string` | (n int) | get the n-digit random string. | +| `max` | (m,n int) | get the maximum of two numbers m and n. | +| `md5` | (s string) | get the MD5 of the input string s. | diff --git a/hrp/extract.go b/hrp/extract.go new file mode 100644 index 00000000..b4269939 --- /dev/null +++ b/hrp/extract.go @@ -0,0 +1,33 @@ +package hrp + +import "fmt" + +// StepRequestExtraction implements IStep interface. +type StepRequestExtraction struct { + step *TStep +} + +// WithJmesPath sets the JMESPath expression to extract from the response. +func (s *StepRequestExtraction) WithJmesPath(jmesPath string, varName string) *StepRequestExtraction { + s.step.Extract[varName] = jmesPath + return s +} + +// Validate switches to step validation. +func (s *StepRequestExtraction) Validate() *StepRequestValidation { + return &StepRequestValidation{ + step: s.step, + } +} + +func (s *StepRequestExtraction) Name() string { + return s.step.Name +} + +func (s *StepRequestExtraction) Type() string { + return fmt.Sprintf("request-%v", s.step.Request.Method) +} + +func (s *StepRequestExtraction) ToStruct() *TStep { + return s.step +} diff --git a/hrp/internal/boomer/README.md b/hrp/internal/boomer/README.md new file mode 100644 index 00000000..b6ef5ce2 --- /dev/null +++ b/hrp/internal/boomer/README.md @@ -0,0 +1,5 @@ +# boomer + +This module is initially forked from [myzhan/boomer] and made a lot of changes. + +[myzhan/boomer]: https://github.com/myzhan/boomer diff --git a/hrp/internal/boomer/boomer.go b/hrp/internal/boomer/boomer.go new file mode 100644 index 00000000..6d22a10c --- /dev/null +++ b/hrp/internal/boomer/boomer.go @@ -0,0 +1,171 @@ +package boomer + +import ( + "math" + "os" + "os/signal" + "syscall" + "time" + + "github.com/rs/zerolog/log" +) + +// A Boomer is used to run tasks. +type Boomer struct { + localRunner *localRunner + + cpuProfile string + cpuProfileDuration time.Duration + + memoryProfile string + memoryProfileDuration time.Duration + + disableKeepalive bool + disableCompression bool +} + +// NewStandaloneBoomer returns a new Boomer, which can run without master. +func NewStandaloneBoomer(spawnCount int, spawnRate float64) *Boomer { + return &Boomer{ + localRunner: newLocalRunner(spawnCount, spawnRate), + } +} + +// SetRateLimiter creates rate limiter with the given limit and burst. +func (b *Boomer) SetRateLimiter(maxRPS int64, requestIncreaseRate string) { + var rateLimiter RateLimiter + var err error + if requestIncreaseRate != "-1" { + if maxRPS <= 0 { + maxRPS = math.MaxInt64 + } + log.Warn().Int64("maxRPS", maxRPS).Str("increaseRate", requestIncreaseRate).Msg("set ramp up rate limiter") + rateLimiter, err = NewRampUpRateLimiter(maxRPS, requestIncreaseRate, time.Second) + } else { + if maxRPS > 0 { + log.Warn().Int64("maxRPS", maxRPS).Msg("set stable rate limiter") + rateLimiter = NewStableRateLimiter(maxRPS, time.Second) + } + } + if err != nil { + log.Error().Err(err).Msg("failed to create rate limiter") + return + } + + if rateLimiter != nil { + b.localRunner.rateLimitEnabled = true + b.localRunner.rateLimiter = rateLimiter + } +} + +// SetDisableKeepAlive disable keep-alive for tcp +func (b *Boomer) SetDisableKeepAlive(disableKeepalive bool) { + b.disableKeepalive = disableKeepalive +} + +// SetDisableCompression disable compression to prevent the Transport from requesting compression with an "Accept-Encoding: gzip" +func (b *Boomer) SetDisableCompression(disableCompression bool) { + b.disableCompression = disableCompression +} + +func (b *Boomer) GetDisableKeepAlive() bool { + return b.disableKeepalive +} + +func (b *Boomer) GetDisableCompression() bool { + return b.disableCompression +} + +// SetLoopCount set loop count for test. +func (b *Boomer) SetLoopCount(loopCount int64) { + b.localRunner.loop = &Loop{loopCount: loopCount} +} + +// AddOutput accepts outputs which implements the boomer.Output interface. +func (b *Boomer) AddOutput(o Output) { + b.localRunner.addOutput(o) +} + +// EnableCPUProfile will start cpu profiling after run. +func (b *Boomer) EnableCPUProfile(cpuProfile string, duration time.Duration) { + b.cpuProfile = cpuProfile + b.cpuProfileDuration = duration +} + +// EnableMemoryProfile will start memory profiling after run. +func (b *Boomer) EnableMemoryProfile(memoryProfile string, duration time.Duration) { + b.memoryProfile = memoryProfile + b.memoryProfileDuration = duration +} + +// EnableGracefulQuit catch SIGINT and SIGTERM signals to quit gracefully +func (b *Boomer) EnableGracefulQuit() { + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-c + b.Quit() + }() +} + +// Run accepts a slice of Task and connects to the locust master. +func (b *Boomer) Run(tasks ...*Task) { + if b.cpuProfile != "" { + err := startCPUProfile(b.cpuProfile, b.cpuProfileDuration) + if err != nil { + log.Error().Err(err).Msg("failed to start cpu profiling") + } + } + if b.memoryProfile != "" { + err := startMemoryProfile(b.memoryProfile, b.memoryProfileDuration) + if err != nil { + log.Error().Err(err).Msg("failed to start memory profiling") + } + } + + b.localRunner.setTasks(tasks) + b.localRunner.start() +} + +// RecordTransaction reports a transaction stat. +func (b *Boomer) RecordTransaction(name string, success bool, elapsedTime int64, contentSize int64) { + b.localRunner.stats.transactionChan <- &transaction{ + name: name, + success: success, + elapsedTime: elapsedTime, + contentSize: contentSize, + } +} + +// RecordSuccess reports a success. +func (b *Boomer) RecordSuccess(requestType, name string, responseTime int64, responseLength int64) { + b.localRunner.stats.requestSuccessChan <- &requestSuccess{ + requestType: requestType, + name: name, + responseTime: responseTime, + responseLength: responseLength, + } +} + +// RecordFailure reports a failure. +func (b *Boomer) RecordFailure(requestType, name string, responseTime int64, exception string) { + b.localRunner.stats.requestFailureChan <- &requestFailure{ + requestType: requestType, + name: name, + responseTime: responseTime, + errMsg: exception, + } +} + +// Quit will send a quit message to the master. +func (b *Boomer) Quit() { + b.localRunner.stop() +} + +func (b *Boomer) GetSpawnDoneChan() chan struct{} { + return b.localRunner.spawnDone +} + +func (b *Boomer) GetSpawnCount() int { + return b.localRunner.spawnCount +} diff --git a/hrp/internal/boomer/boomer_test.go b/hrp/internal/boomer/boomer_test.go new file mode 100644 index 00000000..7f113f87 --- /dev/null +++ b/hrp/internal/boomer/boomer_test.go @@ -0,0 +1,146 @@ +package boomer + +import ( + "math" + "os" + "runtime" + "sync/atomic" + "testing" + "time" +) + +func TestNewStandaloneBoomer(t *testing.T) { + b := NewStandaloneBoomer(100, 10) + + if b.localRunner.spawnCount != 100 { + t.Error("spawnCount should be 100") + } + + if b.localRunner.spawnRate != 10 { + t.Error("spawnRate should be 10") + } +} + +func TestSetRateLimiter(t *testing.T) { + b := NewStandaloneBoomer(100, 10) + b.SetRateLimiter(10, "10/1s") + + if b.localRunner.rateLimiter == nil { + t.Error("b.rateLimiter should not be nil") + } +} + +func TestAddOutput(t *testing.T) { + b := NewStandaloneBoomer(100, 10) + b.AddOutput(NewConsoleOutput()) + b.AddOutput(NewConsoleOutput()) + + if len(b.localRunner.outputs) != 2 { + t.Error("length of outputs should be 2") + } +} + +func TestEnableCPUProfile(t *testing.T) { + b := NewStandaloneBoomer(100, 10) + b.EnableCPUProfile("cpu.prof", time.Second) + + if b.cpuProfile != "cpu.prof" { + t.Error("cpuProfile should be cpu.prof") + } + + if b.cpuProfileDuration != time.Second { + t.Error("cpuProfileDuration should 1 second") + } +} + +func TestEnableMemoryProfile(t *testing.T) { + b := NewStandaloneBoomer(100, 10) + b.EnableMemoryProfile("mem.prof", time.Second) + + if b.memoryProfile != "mem.prof" { + t.Error("memoryProfile should be mem.prof") + } + + if b.memoryProfileDuration != time.Second { + t.Error("memoryProfileDuration should 1 second") + } +} + +func TestStandaloneRun(t *testing.T) { + b := NewStandaloneBoomer(10, 10) + b.EnableCPUProfile("cpu.pprof", 2*time.Second) + b.EnableMemoryProfile("mem.pprof", 2*time.Second) + + count := int64(0) + taskA := &Task{ + Name: "increaseCount", + Fn: func() { + atomic.AddInt64(&count, 1) + runtime.Goexit() + }, + } + go b.Run(taskA) + + time.Sleep(5 * time.Second) + + b.Quit() + + if atomic.LoadInt64(&count) != 10 { + t.Error("count is", count, "expected: 10") + } + + if _, err := os.Stat("cpu.pprof"); os.IsNotExist(err) { + t.Error("File cpu.pprof is not generated") + } else { + os.Remove("cpu.pprof") + } + + if _, err := os.Stat("mem.pprof"); os.IsNotExist(err) { + t.Error("File mem.pprof is not generated") + } else { + os.Remove("mem.pprof") + } +} + +func TestCreateRatelimiter(t *testing.T) { + b := NewStandaloneBoomer(10, 10) + b.SetRateLimiter(100, "-1") + + if stableRateLimiter, ok := b.localRunner.rateLimiter.(*StableRateLimiter); !ok { + t.Error("Expected stableRateLimiter") + } else { + if stableRateLimiter.threshold != 100 { + t.Error("threshold should be equals to math.MaxInt64, was", stableRateLimiter.threshold) + } + } + + b.SetRateLimiter(0, "1") + if rampUpRateLimiter, ok := b.localRunner.rateLimiter.(*RampUpRateLimiter); !ok { + t.Error("Expected rampUpRateLimiter") + } else { + if rampUpRateLimiter.maxThreshold != math.MaxInt64 { + t.Error("maxThreshold should be equals to math.MaxInt64, was", rampUpRateLimiter.maxThreshold) + } + if rampUpRateLimiter.rampUpRate != "1" { + t.Error("rampUpRate should be equals to \"1\", was", rampUpRateLimiter.rampUpRate) + } + } + + b.SetRateLimiter(10, "2/2s") + if rampUpRateLimiter, ok := b.localRunner.rateLimiter.(*RampUpRateLimiter); !ok { + t.Error("Expected rampUpRateLimiter") + } else { + if rampUpRateLimiter.maxThreshold != 10 { + t.Error("maxThreshold should be equals to 10, was", rampUpRateLimiter.maxThreshold) + } + if rampUpRateLimiter.rampUpRate != "2/2s" { + t.Error("rampUpRate should be equals to \"2/2s\", was", rampUpRateLimiter.rampUpRate) + } + if rampUpRateLimiter.rampUpStep != 2 { + t.Error("rampUpStep should be equals to 2, was", rampUpRateLimiter.rampUpStep) + } + if rampUpRateLimiter.rampUpPeroid != 2*time.Second { + t.Error("rampUpPeroid should be equals to 2 seconds, was", rampUpRateLimiter.rampUpPeroid) + } + } +} diff --git a/hrp/internal/boomer/output.go b/hrp/internal/boomer/output.go new file mode 100644 index 00000000..8d1d125c --- /dev/null +++ b/hrp/internal/boomer/output.go @@ -0,0 +1,532 @@ +package boomer + +import ( + "fmt" + "os" + "sort" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/olekukonko/tablewriter" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/push" + "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/hrp/internal/json" +) + +// Output is primarily responsible for printing test results to different destinations +// such as consoles, files. You can write you own output and add to boomer. +// When running in standalone mode, the default output is ConsoleOutput, you can add more. +// When running in distribute mode, test results will be reported to master with or without +// an output. +// All the OnXXX function will be call in a separated goroutine, just in case some output will block. +// But it will wait for all outputs return to avoid data lost. +type Output interface { + // OnStart will be call before the test starts. + OnStart() + + // By default, each output receive stats data from runner every three seconds. + // OnEvent is responsible for dealing with the data. + OnEvent(data map[string]interface{}) + + // OnStop will be called before the test ends. + OnStop() +} + +// ConsoleOutput is the default output for standalone mode. +type ConsoleOutput struct { +} + +// NewConsoleOutput returns a ConsoleOutput. +func NewConsoleOutput() *ConsoleOutput { + return &ConsoleOutput{} +} + +func getMedianResponseTime(numRequests int64, responseTimes map[int64]int64) int64 { + medianResponseTime := int64(0) + if len(responseTimes) != 0 { + pos := (numRequests - 1) / 2 + var sortedKeys []int64 + for k := range responseTimes { + sortedKeys = append(sortedKeys, k) + } + sort.Slice(sortedKeys, func(i, j int) bool { + return sortedKeys[i] < sortedKeys[j] + }) + for _, k := range sortedKeys { + if pos < responseTimes[k] { + medianResponseTime = k + break + } + pos -= responseTimes[k] + } + } + return medianResponseTime +} + +func getAvgResponseTime(numRequests int64, totalResponseTime int64) (avgResponseTime float64) { + avgResponseTime = float64(0) + if numRequests != 0 { + avgResponseTime = float64(totalResponseTime) / float64(numRequests) + } + return avgResponseTime +} + +func getAvgContentLength(numRequests int64, totalContentLength int64) (avgContentLength int64) { + avgContentLength = int64(0) + if numRequests != 0 { + avgContentLength = totalContentLength / numRequests + } + return avgContentLength +} + +func getCurrentRps(numRequests int64, duration float64) (currentRps float64) { + currentRps = float64(numRequests) / duration + return currentRps +} + +func getCurrentFailPerSec(numFailures int64, duration float64) (currentFailPerSec float64) { + currentFailPerSec = float64(numFailures) / duration + return currentFailPerSec +} + +func getTotalFailRatio(totalRequests, totalFailures int64) (failRatio float64) { + if totalRequests == 0 { + return 0 + } + return float64(totalFailures) / float64(totalRequests) +} + +// OnStart of ConsoleOutput has nothing to do. +func (o *ConsoleOutput) OnStart() { + +} + +// OnStop of ConsoleOutput has nothing to do. +func (o *ConsoleOutput) OnStop() { + +} + +// OnEvent will print to the console. +func (o *ConsoleOutput) OnEvent(data map[string]interface{}) { + output, err := convertData(data) + if err != nil { + log.Error().Err(err).Msg("failed to convert data") + return + } + + var state string + switch output.State { + case stateInit: + state = "initializing" + case stateSpawning: + state = "spawning" + case stateRunning: + state = "running" + case stateQuitting: + state = "quitting" + case stateStopped: + state = "stopped" + } + + currentTime := time.Now() + println(fmt.Sprintf("Current time: %s, Users: %d, State: %s, Total RPS: %.1f, Total Average Response Time: %.1fms, Total Fail Ratio: %.1f%%", + currentTime.Format("2006/01/02 15:04:05"), output.UserCount, state, output.TotalRPS, output.TotalAvgResponseTime, output.TotalFailRatio*100)) + println(fmt.Sprintf("Accumulated Transactions: %d Passed, %d Failed", + output.TransactionsPassed, output.TransactionsFailed)) + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Type", "Name", "# requests", "# fails", "Median", "Average", "Min", "Max", "Content Size", "# reqs/sec", "# fails/sec"}) + + for _, stat := range output.Stats { + row := make([]string, 11) + row[0] = stat.Method + row[1] = stat.Name + row[2] = strconv.FormatInt(stat.NumRequests, 10) + row[3] = strconv.FormatInt(stat.NumFailures, 10) + row[4] = strconv.FormatInt(stat.medianResponseTime, 10) + row[5] = strconv.FormatFloat(stat.avgResponseTime, 'f', 2, 64) + row[6] = strconv.FormatInt(stat.MinResponseTime, 10) + row[7] = strconv.FormatInt(stat.MaxResponseTime, 10) + row[8] = strconv.FormatInt(stat.avgContentLength, 10) + row[9] = strconv.FormatFloat(stat.currentRps, 'f', 2, 64) + row[10] = strconv.FormatFloat(stat.currentFailPerSec, 'f', 2, 64) + table.Append(row) + } + table.Render() + println() +} + +type statsEntryOutput struct { + statsEntry + + medianResponseTime int64 // median response time + avgResponseTime float64 // average response time, round float to 2 decimal places + avgContentLength int64 // average content size + currentRps float64 // # reqs/sec + currentFailPerSec float64 // # fails/sec +} + +type dataOutput struct { + UserCount int32 `json:"user_count"` + State int32 `json:"state"` + TotalStats *statsEntryOutput `json:"stats_total"` + TransactionsPassed int64 `json:"transactions_passed"` + TransactionsFailed int64 `json:"transactions_failed"` + TotalAvgResponseTime float64 `json:"total_avg_response_time"` + TotalRPS float64 `json:"total_rps"` + TotalFailRatio float64 `json:"total_fail_ratio"` + Stats []*statsEntryOutput `json:"stats"` + Errors map[string]map[string]interface{} `json:"errors"` +} + +func convertData(data map[string]interface{}) (output *dataOutput, err error) { + userCount, ok := data["user_count"].(int32) + if !ok { + return nil, fmt.Errorf("user_count is not int32") + } + state, ok := data["state"].(int32) + if !ok { + return nil, fmt.Errorf("state is not int32") + } + stats, ok := data["stats"].([]interface{}) + if !ok { + return nil, fmt.Errorf("stats is not []interface{}") + } + + errors := data["errors"].(map[string]map[string]interface{}) + + transactions, ok := data["transactions"].(map[string]int64) + if !ok { + return nil, fmt.Errorf("transactions is not map[string]int64") + } + transactionsPassed := transactions["passed"] + transactionsFailed := transactions["failed"] + + // convert stats in total + statsTotal, ok := data["stats_total"].(interface{}) + if !ok { + return nil, fmt.Errorf("stats_total is not interface{}") + } + entryTotalOutput, err := deserializeStatsEntry(statsTotal) + if err != nil { + return nil, err + } + + output = &dataOutput{ + UserCount: userCount, + State: state, + TotalStats: entryTotalOutput, + TransactionsPassed: transactionsPassed, + TransactionsFailed: transactionsFailed, + TotalAvgResponseTime: entryTotalOutput.avgResponseTime, + TotalRPS: entryTotalOutput.currentRps, + TotalFailRatio: getTotalFailRatio(entryTotalOutput.NumRequests, entryTotalOutput.NumFailures), + Stats: make([]*statsEntryOutput, 0, len(stats)), + Errors: errors, + } + + // convert stats + for _, stat := range stats { + entryOutput, err := deserializeStatsEntry(stat) + if err != nil { + return nil, err + } + output.Stats = append(output.Stats, entryOutput) + } + // sort stats by type + sort.Slice(output.Stats, func(i, j int) bool { + return output.Stats[i].Method < output.Stats[j].Method + }) + return +} + +func deserializeStatsEntry(stat interface{}) (entryOutput *statsEntryOutput, err error) { + statBytes, err := json.Marshal(stat) + if err != nil { + return nil, err + } + entry := statsEntry{} + if err = json.Unmarshal(statBytes, &entry); err != nil { + return nil, err + } + + var duration float64 + if entry.Name == "Total" { + duration = float64(entry.LastRequestTimestamp - entry.StartTime) + // fix: avoid divide by zero + if duration < 1 { + duration = 1 + } + } else { + duration = float64(reportStatsInterval / time.Second) + } + + numRequests := entry.NumRequests + entryOutput = &statsEntryOutput{ + statsEntry: entry, + medianResponseTime: getMedianResponseTime(numRequests, entry.ResponseTimes), + avgResponseTime: getAvgResponseTime(numRequests, entry.TotalResponseTime), + avgContentLength: getAvgContentLength(numRequests, entry.TotalContentLength), + currentRps: getCurrentRps(numRequests, duration), + currentFailPerSec: getCurrentFailPerSec(entry.NumFailures, duration), + } + return +} + +// gauge vectors for requests +var ( + gaugeNumRequests = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "num_requests", + Help: "The number of requests", + }, + []string{"method", "name"}, + ) + gaugeNumFailures = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "num_failures", + Help: "The number of failures", + }, + []string{"method", "name"}, + ) + gaugeMedianResponseTime = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "median_response_time", + Help: "The median response time", + }, + []string{"method", "name"}, + ) + gaugeAverageResponseTime = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "average_response_time", + Help: "The average response time", + }, + []string{"method", "name"}, + ) + gaugeMinResponseTime = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "min_response_time", + Help: "The min response time", + }, + []string{"method", "name"}, + ) + gaugeMaxResponseTime = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "max_response_time", + Help: "The max response time", + }, + []string{"method", "name"}, + ) + gaugeAverageContentLength = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "average_content_length", + Help: "The average content length", + }, + []string{"method", "name"}, + ) + gaugeCurrentRPS = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "current_rps", + Help: "The current requests per second", + }, + []string{"method", "name"}, + ) + gaugeCurrentFailPerSec = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "current_fail_per_sec", + Help: "The current failure number per second", + }, + []string{"method", "name"}, + ) +) + +// counter for total +var ( + counterErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "errors", + Help: "The errors of load testing", + }, + []string{"method", "name", "error"}, + ) +) + +// summary for total +var ( + summaryResponseTime = prometheus.NewSummaryVec( + prometheus.SummaryOpts{ + Name: "response_time", + Help: "The summary of response time", + Objectives: map[float64]float64{ + 0.5: 0.01, + 0.9: 0.01, + 0.95: 0.005, + }, + AgeBuckets: 1, + MaxAge: 100000 * time.Second, + }, + []string{"method", "name"}, + ) +) + +// gauges for total +var ( + gaugeUsers = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "users", + Help: "The current number of users", + }, + ) + gaugeState = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "state", + Help: "The current runner state, 1=initializing, 2=spawning, 3=running, 4=quitting, 5=stopped", + }, + ) + gaugeTotalAverageResponseTime = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "total_average_response_time", + Help: "The average response time in total milliseconds", + }, + ) + gaugeTotalRPS = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "total_rps", + Help: "The requests per second in total", + }, + ) + gaugeTotalFailRatio = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "fail_ratio", + Help: "The ratio of request failures in total", + }, + ) + gaugeTransactionsPassed = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "transactions_passed", + Help: "The accumulated number of passed transactions", + }, + ) + gaugeTransactionsFailed = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "transactions_failed", + Help: "The accumulated number of failed transactions", + }, + ) +) + +// NewPrometheusPusherOutput returns a PrometheusPusherOutput. +func NewPrometheusPusherOutput(gatewayURL, jobName string) *PrometheusPusherOutput { + nodeUUID, _ := uuid.NewUUID() + return &PrometheusPusherOutput{ + pusher: push.New(gatewayURL, jobName).Grouping("instance", nodeUUID.String()), + } +} + +// PrometheusPusherOutput pushes boomer stats to Prometheus Pushgateway. +type PrometheusPusherOutput struct { + pusher *push.Pusher // Prometheus Pushgateway Pusher +} + +// OnStart will register all prometheus metric collectors +func (o *PrometheusPusherOutput) OnStart() { + log.Info().Msg("register prometheus metric collectors") + registry := prometheus.NewRegistry() + registry.MustRegister( + // gauge vectors for requests + gaugeNumRequests, + gaugeNumFailures, + gaugeMedianResponseTime, + gaugeAverageResponseTime, + gaugeMinResponseTime, + gaugeMaxResponseTime, + gaugeAverageContentLength, + gaugeCurrentRPS, + gaugeCurrentFailPerSec, + // counter for total + counterErrors, + // summary for total + summaryResponseTime, + // gauges for total + gaugeUsers, + gaugeState, + gaugeTotalAverageResponseTime, + gaugeTotalRPS, + gaugeTotalFailRatio, + gaugeTransactionsPassed, + gaugeTransactionsFailed, + ) + o.pusher = o.pusher.Gatherer(registry) +} + +// OnStop of PrometheusPusherOutput has nothing to do. +func (o *PrometheusPusherOutput) OnStop() { + // update runner state: stopped + gaugeState.Set(float64(stateStopped)) + if err := o.pusher.Push(); err != nil { + log.Error().Err(err).Msg("push to Pushgateway failed") + } +} + +// OnEvent will push metric to Prometheus Pushgataway +func (o *PrometheusPusherOutput) OnEvent(data map[string]interface{}) { + output, err := convertData(data) + if err != nil { + log.Error().Err(err).Msg("failed to convert data") + return + } + + // user count + gaugeUsers.Set(float64(output.UserCount)) + + // runner state + gaugeState.Set(float64(output.State)) + + // avg response time in total + gaugeTotalAverageResponseTime.Set(output.TotalAvgResponseTime) + + // rps in total + gaugeTotalRPS.Set(output.TotalRPS) + + // failure ratio in total + gaugeTotalFailRatio.Set(output.TotalFailRatio) + + // accumulated number of transactions + gaugeTransactionsPassed.Set(float64(output.TransactionsPassed)) + gaugeTransactionsFailed.Set(float64(output.TransactionsFailed)) + + for _, stat := range output.Stats { + method := stat.Method + name := stat.Name + gaugeNumRequests.WithLabelValues(method, name).Set(float64(stat.NumRequests)) + gaugeNumFailures.WithLabelValues(method, name).Set(float64(stat.NumFailures)) + gaugeMedianResponseTime.WithLabelValues(method, name).Set(float64(stat.medianResponseTime)) + gaugeAverageResponseTime.WithLabelValues(method, name).Set(float64(stat.avgResponseTime)) + gaugeMinResponseTime.WithLabelValues(method, name).Set(float64(stat.MinResponseTime)) + gaugeMaxResponseTime.WithLabelValues(method, name).Set(float64(stat.MaxResponseTime)) + gaugeAverageContentLength.WithLabelValues(method, name).Set(float64(stat.avgContentLength)) + gaugeCurrentRPS.WithLabelValues(method, name).Set(stat.currentRps) + gaugeCurrentFailPerSec.WithLabelValues(method, name).Set(stat.currentFailPerSec) + for responseTime, count := range stat.ResponseTimes { + var i int64 + for i = 0; i < count; i++ { + summaryResponseTime.WithLabelValues(method, name).Observe(float64(responseTime)) + } + } + } + + // errors + for _, requestError := range output.Errors { + counterErrors.WithLabelValues( + requestError["method"].(string), + requestError["name"].(string), + requestError["error"].(string), + ).Add(float64(requestError["occurrences"].(int64))) + } + + if err := o.pusher.Push(); err != nil { + log.Error().Err(err).Msg("push to Pushgateway failed") + } +} diff --git a/hrp/internal/boomer/output_test.go b/hrp/internal/boomer/output_test.go new file mode 100644 index 00000000..58e3dee1 --- /dev/null +++ b/hrp/internal/boomer/output_test.go @@ -0,0 +1,104 @@ +package boomer + +import ( + "fmt" + "math" + "testing" +) + +func TestGetMedianResponseTime(t *testing.T) { + numRequests := int64(10) + responseTimes := map[int64]int64{ + 100: 1, + 200: 3, + 300: 6, + } + + medianResponseTime := getMedianResponseTime(numRequests, responseTimes) + if medianResponseTime != 300 { + t.Error("medianResponseTime should be 300") + } + + responseTimes = map[int64]int64{} + + medianResponseTime = getMedianResponseTime(numRequests, responseTimes) + if medianResponseTime != 0 { + t.Error("medianResponseTime should be 0") + } +} + +func TestGetAvgResponseTime(t *testing.T) { + numRequests := int64(3) + totalResponseTime := int64(100) + + avgResponseTime := getAvgResponseTime(numRequests, totalResponseTime) + if math.Dim(float64(33.33), avgResponseTime) > 0.01 { + t.Error("avgResponseTime should be close to 33.33") + } + + avgResponseTime = getAvgResponseTime(int64(0), totalResponseTime) + if avgResponseTime != float64(0) { + t.Error("avgResponseTime should be close to 0") + } +} + +func TestGetAvgContentLength(t *testing.T) { + numRequests := int64(3) + totalContentLength := int64(100) + + avgContentLength := getAvgContentLength(numRequests, totalContentLength) + if avgContentLength != 33 { + t.Error("avgContentLength should be 33") + } + + avgContentLength = getAvgContentLength(int64(0), totalContentLength) + if avgContentLength != 0 { + t.Error("avgContentLength should be 0") + } +} + +func TestGetCurrentRps(t *testing.T) { + duration := float64(3) + numRequests := int64(6) + currentRps := getCurrentRps(numRequests, duration) + if currentRps != 2 { + t.Error("currentRps should be 2") + } + + numRequests = int64(8) + currentRps = getCurrentRps(numRequests, duration) + if fmt.Sprintf("%.2f", currentRps) != "2.67" { + t.Error("currentRps should be 2.67") + } +} + +func TestConsoleOutput(t *testing.T) { + o := NewConsoleOutput() + o.OnStart() + + data := map[string]interface{}{} + stat := map[string]interface{}{} + data["stats"] = []interface{}{stat} + + stat["name"] = "http" + stat["method"] = "post" + stat["num_requests"] = int64(100) + stat["num_failures"] = int64(10) + stat["response_times"] = map[int64]int64{ + 10: 1, + 100: 99, + } + stat["total_response_time"] = int64(9910) + stat["min_response_time"] = int64(10) + stat["max_response_time"] = int64(100) + stat["total_content_length"] = int64(100000) + stat["num_reqs_per_sec"] = map[int64]int64{ + 1: 20, + 2: 40, + 3: 40, + } + + o.OnEvent(data) + + o.OnStop() +} diff --git a/hrp/internal/boomer/ratelimiter.go b/hrp/internal/boomer/ratelimiter.go new file mode 100644 index 00000000..d131c4d5 --- /dev/null +++ b/hrp/internal/boomer/ratelimiter.go @@ -0,0 +1,230 @@ +package boomer + +import ( + "errors" + "math" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +// RateLimiter is used to put limits on task executions. +type RateLimiter interface { + // Start is used to enable the rate limiter. + // It can be implemented as a noop if not needed. + Start() + + // Acquire() is called before executing a task.Fn function. + // If Acquire() returns true, the task.Fn function will be executed. + // If Acquire() returns false, the task.Fn function won't be executed this time, but Acquire() will be called very soon. + // It works like: + // for { + // blocked := rateLimiter.Acquire() + // if !blocked { + // task.Fn() + // } + // } + // Acquire() should block the caller until execution is allowed. + Acquire() bool + + // Stop is used to disable the rate limiter. + // It can be implemented as a noop if not needed. + Stop() +} + +// A StableRateLimiter uses the token bucket algorithm. +// the bucket is refilled according to the refill period, no burst is allowed. +type StableRateLimiter struct { + threshold int64 + currentThreshold int64 + refillPeriod time.Duration + broadcastChanMux *sync.RWMutex // avoid data race + broadcastChannel chan bool + quitChannel chan bool +} + +// NewStableRateLimiter returns a StableRateLimiter. +func NewStableRateLimiter(threshold int64, refillPeriod time.Duration) (rateLimiter *StableRateLimiter) { + rateLimiter = &StableRateLimiter{ + threshold: threshold, + currentThreshold: threshold, + refillPeriod: refillPeriod, + broadcastChanMux: new(sync.RWMutex), + broadcastChannel: make(chan bool), + } + return rateLimiter +} + +// Start to refill the bucket periodically. +func (limiter *StableRateLimiter) Start() { + limiter.quitChannel = make(chan bool) + quitChannel := limiter.quitChannel + go func() { + for { + select { + case <-quitChannel: + return + default: + atomic.StoreInt64(&limiter.currentThreshold, limiter.threshold) + time.Sleep(limiter.refillPeriod) + close(limiter.broadcastChannel) + // avoid data race + limiter.broadcastChanMux.Lock() + limiter.broadcastChannel = make(chan bool) + limiter.broadcastChanMux.Unlock() + } + } + }() +} + +// Acquire a token from the bucket, returns true if the bucket is exhausted. +func (limiter *StableRateLimiter) Acquire() (blocked bool) { + permit := atomic.AddInt64(&limiter.currentThreshold, -1) + if permit < 0 { + blocked = true + // block until the bucket is refilled + limiter.broadcastChanMux.Lock() + <-limiter.broadcastChannel + limiter.broadcastChanMux.Unlock() + } else { + blocked = false + } + return blocked +} + +// Stop the rate limiter. +func (limiter *StableRateLimiter) Stop() { + close(limiter.quitChannel) +} + +// ErrParsingRampUpRate is the error returned if the format of rampUpRate is invalid. +var ErrParsingRampUpRate = errors.New("ratelimiter: invalid format of rampUpRate, try \"1\" or \"1/1s\"") + +// A RampUpRateLimiter uses the token bucket algorithm. +// the threshold is updated according to the warm up rate. +// the bucket is refilled according to the refill period, no burst is allowed. +type RampUpRateLimiter struct { + maxThreshold int64 + nextThreshold int64 + currentThreshold int64 + refillPeriod time.Duration + rampUpRate string + rampUpStep int64 + rampUpPeroid time.Duration + + broadcastChanMux *sync.RWMutex // avoid data race + broadcastChannel chan bool + + rampUpChannel chan bool + quitChannel chan bool +} + +// NewRampUpRateLimiter returns a RampUpRateLimiter. +// Valid formats of rampUpRate are "1", "1/1s". +func NewRampUpRateLimiter(maxThreshold int64, rampUpRate string, refillPeriod time.Duration) (rateLimiter *RampUpRateLimiter, err error) { + rateLimiter = &RampUpRateLimiter{ + maxThreshold: maxThreshold, + nextThreshold: 0, + currentThreshold: 0, + rampUpRate: rampUpRate, + refillPeriod: refillPeriod, + broadcastChanMux: new(sync.RWMutex), + broadcastChannel: make(chan bool), + } + rateLimiter.rampUpStep, rateLimiter.rampUpPeroid, err = rateLimiter.parseRampUpRate(rateLimiter.rampUpRate) + if err != nil { + return nil, err + } + return rateLimiter, nil +} + +func (limiter *RampUpRateLimiter) parseRampUpRate(rampUpRate string) (rampUpStep int64, rampUpPeroid time.Duration, err error) { + if strings.Contains(rampUpRate, "/") { + tmp := strings.Split(rampUpRate, "/") + if len(tmp) != 2 { + return rampUpStep, rampUpPeroid, ErrParsingRampUpRate + } + rampUpStep, err := strconv.ParseInt(tmp[0], 10, 64) + if err != nil { + return rampUpStep, rampUpPeroid, ErrParsingRampUpRate + } + rampUpPeroid, err := time.ParseDuration(tmp[1]) + if err != nil { + return rampUpStep, rampUpPeroid, ErrParsingRampUpRate + } + return rampUpStep, rampUpPeroid, nil + } + + rampUpStep, err = strconv.ParseInt(rampUpRate, 10, 64) + if err != nil { + return rampUpStep, rampUpPeroid, ErrParsingRampUpRate + } + rampUpPeroid = time.Second + return rampUpStep, rampUpPeroid, nil +} + +// Start to refill the bucket periodically. +func (limiter *RampUpRateLimiter) Start() { + limiter.quitChannel = make(chan bool) + quitChannel := limiter.quitChannel + // bucket updater + go func() { + for { + select { + case <-quitChannel: + return + default: + atomic.StoreInt64(&limiter.currentThreshold, atomic.LoadInt64(&limiter.nextThreshold)) + time.Sleep(limiter.refillPeriod) + close(limiter.broadcastChannel) + // avoid data race + limiter.broadcastChanMux.Lock() + limiter.broadcastChannel = make(chan bool) + limiter.broadcastChanMux.Unlock() + } + } + }() + // threshold updater + go func() { + for { + select { + case <-quitChannel: + return + default: + nextValue := atomic.LoadInt64(&limiter.nextThreshold) + limiter.rampUpStep + if nextValue < 0 { + // int64 overflow + nextValue = int64(math.MaxInt64) + } + if nextValue > limiter.maxThreshold { + nextValue = limiter.maxThreshold + } + atomic.StoreInt64(&limiter.nextThreshold, nextValue) + time.Sleep(limiter.rampUpPeroid) + } + } + }() +} + +// Acquire a token from the bucket, returns true if the bucket is exhausted. +func (limiter *RampUpRateLimiter) Acquire() (blocked bool) { + permit := atomic.AddInt64(&limiter.currentThreshold, -1) + if permit < 0 { + blocked = true + // block until the bucket is refilled + limiter.broadcastChanMux.Lock() + <-limiter.broadcastChannel + limiter.broadcastChanMux.Unlock() + } else { + blocked = false + } + return blocked +} + +// Stop the rate limiter. +func (limiter *RampUpRateLimiter) Stop() { + atomic.StoreInt64(&limiter.nextThreshold, 0) + close(limiter.quitChannel) +} diff --git a/hrp/internal/boomer/ratelimiter_test.go b/hrp/internal/boomer/ratelimiter_test.go new file mode 100644 index 00000000..eca839d5 --- /dev/null +++ b/hrp/internal/boomer/ratelimiter_test.go @@ -0,0 +1,102 @@ +package boomer + +import ( + "testing" + "time" +) + +func TestStableRateLimiter(t *testing.T) { + rateLimiter := NewStableRateLimiter(1, 10*time.Millisecond) + rateLimiter.Start() + defer rateLimiter.Stop() + + blocked := rateLimiter.Acquire() + if blocked { + t.Error("Unexpected blocked by rate limiter") + } + blocked = rateLimiter.Acquire() + if !blocked { + t.Error("Should be blocked") + } +} + +// FIXME +// func TestRampUpRateLimiter(t *testing.T) { +// rateLimiter, _ := NewRampUpRateLimiter(100, "10/200ms", 100*time.Millisecond) +// rateLimiter.Start() +// defer rateLimiter.Stop() + +// time.Sleep(150 * time.Millisecond) + +// for i := 0; i < 10; i++ { +// blocked := rateLimiter.Acquire() +// if blocked { +// t.Fatal("Unexpected blocked by rate limiter") +// } +// } +// blocked := rateLimiter.Acquire() +// if !blocked { +// t.Fatal("Should be blocked") +// } + +// time.Sleep(150 * time.Millisecond) + +// // now, the threshold is 20 +// for i := 0; i < 20; i++ { +// blocked := rateLimiter.Acquire() +// if blocked { +// t.Fatal("Unexpected blocked by rate limiter") +// } +// } +// blocked = rateLimiter.Acquire() +// if !blocked { +// t.Fatal("Should be blocked") +// } +// } + +func TestParseRampUpRate(t *testing.T) { + rateLimiter := &RampUpRateLimiter{} + rampUpStep, rampUpPeriod, _ := rateLimiter.parseRampUpRate("100") + if rampUpStep != 100 { + t.Error("Wrong rampUpStep, expected: 100, was:", rampUpStep) + } + if rampUpPeriod != time.Second { + t.Error("Wrong rampUpPeriod, expected: 1s, was:", rampUpPeriod) + } + rampUpStep, rampUpPeriod, _ = rateLimiter.parseRampUpRate("200/10s") + if rampUpStep != 200 { + t.Error("Wrong rampUpStep, expected: 200, was:", rampUpStep) + } + if rampUpPeriod != 10*time.Second { + t.Error("Wrong rampUpPeriod, expected: 10s, was:", rampUpPeriod) + } +} + +func TestParseInvalidRampUpRate(t *testing.T) { + rateLimiter := &RampUpRateLimiter{} + + _, _, err := rateLimiter.parseRampUpRate("A/1m") + if err == nil || err != ErrParsingRampUpRate { + t.Error("Expected ErrParsingRampUpRate") + } + + _, _, err = rateLimiter.parseRampUpRate("A") + if err == nil || err != ErrParsingRampUpRate { + t.Error("Expected ErrParsingRampUpRate") + } + + _, _, err = rateLimiter.parseRampUpRate("200/1s/") + if err == nil || err != ErrParsingRampUpRate { + t.Error("Expected ErrParsingRampUpRate") + } + + _, _, err = rateLimiter.parseRampUpRate("200/1") + if err == nil || err != ErrParsingRampUpRate { + t.Error("Expected ErrParsingRampUpRate") + } + + rateLimiter, err = NewRampUpRateLimiter(1, "200/1", time.Second) + if err == nil || err != ErrParsingRampUpRate { + t.Error("Expected ErrParsingRampUpRate") + } +} diff --git a/hrp/internal/boomer/runner.go b/hrp/internal/boomer/runner.go new file mode 100644 index 00000000..c4da7cfd --- /dev/null +++ b/hrp/internal/boomer/runner.go @@ -0,0 +1,372 @@ +package boomer + +import ( + "fmt" + "math/rand" + "os" + "runtime/debug" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/olekukonko/tablewriter" + + "github.com/rs/zerolog/log" +) + +const ( + stateInit = iota + 1 // initializing + stateSpawning // spawning + stateRunning // running + stateQuitting // quitting + stateStopped // stopped +) + +const ( + reportStatsInterval = 3 * time.Second +) + +type Loop struct { + loopCount int64 // more than 0 + acquiredCount int64 // count acquired of load testing + finishedCount int64 // count finished of load testing +} + +func (l *Loop) isFinished() bool { + // return true when there are no remaining loop count to test + return atomic.LoadInt64(&l.finishedCount) == l.loopCount +} + +func (l *Loop) acquire() bool { + // get one ticket when there are still remaining loop count to test + // return true when getting ticket successfully + if atomic.LoadInt64(&l.acquiredCount) < l.loopCount { + atomic.AddInt64(&l.acquiredCount, 1) + return true + } + return false +} + +func (l *Loop) increaseFinishedCount() { + atomic.AddInt64(&l.finishedCount, 1) +} + +type runner struct { + state int32 + + tasks []*Task + totalTaskWeight int + + rateLimiter RateLimiter + rateLimitEnabled bool + stats *requestStats + + currentClientsNum int32 // current clients count + spawnCount int // target clients to spawn + spawnRate float64 + loop *Loop // specify running cycles + spawnDone chan struct{} + + outputs []Output +} + +// safeRun runs fn and recovers from unexpected panics. +// it prevents panics from Task.Fn crashing boomer. +func (r *runner) safeRun(fn func()) { + defer func() { + // don't panic + err := recover() + if err != nil { + stackTrace := debug.Stack() + errMsg := fmt.Sprintf("%v", err) + os.Stderr.Write([]byte(errMsg)) + os.Stderr.Write([]byte("\n")) + os.Stderr.Write(stackTrace) + } + }() + fn() +} + +func (r *runner) addOutput(o Output) { + r.outputs = append(r.outputs, o) +} + +func (r *runner) outputOnStart() { + size := len(r.outputs) + if size == 0 { + return + } + wg := sync.WaitGroup{} + wg.Add(size) + for _, output := range r.outputs { + go func(o Output) { + o.OnStart() + wg.Done() + }(output) + } + wg.Wait() +} + +func (r *runner) outputOnEvent(data map[string]interface{}) { + size := len(r.outputs) + if size == 0 { + return + } + wg := sync.WaitGroup{} + wg.Add(size) + for _, output := range r.outputs { + go func(o Output) { + o.OnEvent(data) + wg.Done() + }(output) + } + wg.Wait() +} + +func (r *runner) outputOnStop() { + size := len(r.outputs) + if size == 0 { + return + } + wg := sync.WaitGroup{} + wg.Add(size) + for _, output := range r.outputs { + go func(o Output) { + o.OnStop() + wg.Done() + }(output) + } + wg.Wait() +} + +func (r *runner) reportStats() { + data := r.stats.collectReportData() + data["user_count"] = atomic.LoadInt32(&r.currentClientsNum) + data["state"] = atomic.LoadInt32(&r.state) + r.outputOnEvent(data) +} + +func (r *runner) reportTestResult() { + // convert stats in total + var statsTotal interface{} = r.stats.total.serialize() + entryTotalOutput, err := deserializeStatsEntry(statsTotal) + if err != nil { + return + } + duration := time.Duration(entryTotalOutput.LastRequestTimestamp-entryTotalOutput.StartTime) * time.Second + currentTime := time.Now() + println(fmt.Sprint("=========================================== Statistics Summary ==========================================")) + println(fmt.Sprintf("Current time: %s, Users: %v, Duration: %v, Accumulated Transactions: %d Passed, %d Failed", + currentTime.Format("2006/01/02 15:04:05"), atomic.LoadInt32(&r.currentClientsNum), duration, r.stats.transactionPassed, r.stats.transactionFailed)) + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Name", "# requests", "# fails", "Median", "Average", "Min", "Max", "Content Size", "# reqs/sec", "# fails/sec"}) + row := make([]string, 10) + row[0] = entryTotalOutput.Name + row[1] = strconv.FormatInt(entryTotalOutput.NumRequests, 10) + row[2] = strconv.FormatInt(entryTotalOutput.NumFailures, 10) + row[3] = strconv.FormatInt(entryTotalOutput.medianResponseTime, 10) + row[4] = strconv.FormatFloat(entryTotalOutput.avgResponseTime, 'f', 2, 64) + row[5] = strconv.FormatInt(entryTotalOutput.MinResponseTime, 10) + row[6] = strconv.FormatInt(entryTotalOutput.MaxResponseTime, 10) + row[7] = strconv.FormatInt(entryTotalOutput.avgContentLength, 10) + row[8] = strconv.FormatFloat(entryTotalOutput.currentRps, 'f', 2, 64) + row[9] = strconv.FormatFloat(entryTotalOutput.currentFailPerSec, 'f', 2, 64) + table.Append(row) + table.Render() + println() +} + +func (r *localRunner) spawnWorkers(spawnCount int, spawnRate float64, quit chan bool, spawnCompleteFunc func()) { + log.Info(). + Int("spawnCount", spawnCount). + Float64("spawnRate", spawnRate). + Msg("Spawning workers") + + atomic.StoreInt32(&r.state, stateSpawning) + for i := 1; i <= spawnCount; i++ { + // spawn workers with rate limit + sleepTime := time.Duration(1000000/r.spawnRate) * time.Microsecond + time.Sleep(sleepTime) + + select { + case <-quit: + // quit spawning goroutine + log.Info().Msg("Quitting spawning workers") + return + default: + atomic.AddInt32(&r.currentClientsNum, 1) + go func() { + for { + select { + case <-quit: + return + default: + if r.loop != nil && !r.loop.acquire() { + return + } + if r.rateLimitEnabled { + blocked := r.rateLimiter.Acquire() + if !blocked { + task := r.getTask() + r.safeRun(task.Fn) + } + } else { + task := r.getTask() + r.safeRun(task.Fn) + } + if r.loop != nil { + r.loop.increaseFinishedCount() + if r.loop.isFinished() { + r.stop() + } + } + } + } + }() + } + } + + close(r.spawnDone) + if spawnCompleteFunc != nil { + spawnCompleteFunc() + } + atomic.StoreInt32(&r.state, stateRunning) +} + +// setTasks will set the runner's task list AND the total task weight +// which is used to get a random task later +func (r *runner) setTasks(t []*Task) { + r.tasks = t + + weightSum := 0 + for _, task := range r.tasks { + weightSum += task.Weight + } + r.totalTaskWeight = weightSum +} + +func (r *runner) getTask() *Task { + tasksCount := len(r.tasks) + if tasksCount == 1 { + // Fast path + return r.tasks[0] + } + + rs := rand.New(rand.NewSource(time.Now().UnixNano())) + + totalWeight := r.totalTaskWeight + if totalWeight <= 0 { + // If all the tasks have not weights defined, they have the same chance to run + randNum := rs.Intn(tasksCount) + return r.tasks[randNum] + } + + randNum := rs.Intn(totalWeight) + runningSum := 0 + for _, task := range r.tasks { + runningSum += task.Weight + if runningSum > randNum { + return task + } + } + + return nil +} + +type localRunner struct { + runner + + // close this channel will stop all goroutines used in runner. + stopChan chan bool +} + +func newLocalRunner(spawnCount int, spawnRate float64) *localRunner { + return &localRunner{ + runner: runner{ + state: stateInit, + spawnRate: spawnRate, + spawnCount: spawnCount, + stats: newRequestStats(), + outputs: make([]Output, 0), + spawnDone: make(chan struct{}), + }, + stopChan: make(chan bool), + } +} + +func (r *localRunner) start() { + // init state + atomic.StoreInt32(&r.state, stateInit) + atomic.StoreInt32(&r.currentClientsNum, 0) + r.stats.clearAll() + + // start rate limiter + if r.rateLimitEnabled { + r.rateLimiter.Start() + } + + // all running workers(goroutines) will select on this channel. + // close this channel will stop all running workers. + quitChan := make(chan bool) + // when this channel is closed, all statistics are reported successfully + reportedChan := make(chan bool) + go r.spawnWorkers(r.spawnCount, r.spawnRate, quitChan, nil) + + // output setup + r.outputOnStart() + + // start running + go func() { + var ticker = time.NewTicker(reportStatsInterval) + for { + select { + // record stats + case t := <-r.stats.transactionChan: + r.stats.logTransaction(t.name, t.success, t.elapsedTime, t.contentSize) + case m := <-r.stats.requestSuccessChan: + r.stats.logRequest(m.requestType, m.name, m.responseTime, m.responseLength) + case n := <-r.stats.requestFailureChan: + r.stats.logRequest(n.requestType, n.name, n.responseTime, 0) + r.stats.logError(n.requestType, n.name, n.errMsg) + // report stats + case <-ticker.C: + r.reportStats() + // close reportedChan and return if the last stats is reported successfully + if atomic.LoadInt32(&r.state) == stateQuitting { + close(reportedChan) + return + } + } + } + }() + + // stop + <-r.stopChan + atomic.StoreInt32(&r.state, stateQuitting) + + // stop previous goroutines without blocking + // those goroutines will exit when r.safeRun returns + close(quitChan) + + // wait until all stats are reported successfully + <-reportedChan + + // stop rate limiter + if r.rateLimitEnabled { + r.rateLimiter.Stop() + } + + // report test result + r.reportTestResult() + + // output teardown + r.outputOnStop() + + atomic.StoreInt32(&r.state, stateStopped) + return +} + +func (r *localRunner) stop() { + close(r.stopChan) +} diff --git a/hrp/internal/boomer/runner_test.go b/hrp/internal/boomer/runner_test.go new file mode 100644 index 00000000..e6e15992 --- /dev/null +++ b/hrp/internal/boomer/runner_test.go @@ -0,0 +1,116 @@ +package boomer + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type HitOutput struct { + onStart bool + onEvent bool + onStop bool +} + +func (o *HitOutput) OnStart() { + o.onStart = true +} + +func (o *HitOutput) OnEvent(data map[string]interface{}) { + o.onEvent = true +} + +func (o *HitOutput) OnStop() { + o.onStop = true +} + +func TestSafeRun(t *testing.T) { + runner := &runner{} + runner.safeRun(func() { + panic("Runner will catch this panic") + }) +} + +func TestOutputOnStart(t *testing.T) { + hitOutput := &HitOutput{} + hitOutput2 := &HitOutput{} + runner := &runner{} + runner.addOutput(hitOutput) + runner.addOutput(hitOutput2) + runner.outputOnStart() + if !hitOutput.onStart { + t.Error("hitOutput's OnStart has not been called") + } + if !hitOutput2.onStart { + t.Error("hitOutput2's OnStart has not been called") + } +} + +func TestOutputOnEvent(t *testing.T) { + hitOutput := &HitOutput{} + hitOutput2 := &HitOutput{} + runner := &runner{} + runner.addOutput(hitOutput) + runner.addOutput(hitOutput2) + runner.outputOnEvent(nil) + if !hitOutput.onEvent { + t.Error("hitOutput's OnEvent has not been called") + } + if !hitOutput2.onEvent { + t.Error("hitOutput2's OnEvent has not been called") + } +} + +func TestOutputOnStop(t *testing.T) { + hitOutput := &HitOutput{} + hitOutput2 := &HitOutput{} + runner := &runner{} + runner.addOutput(hitOutput) + runner.addOutput(hitOutput2) + runner.outputOnStop() + if !hitOutput.onStop { + t.Error("hitOutput's OnStop has not been called") + } + if !hitOutput2.onStop { + t.Error("hitOutput2's OnStop has not been called") + } +} + +func TestLocalRunner(t *testing.T) { + taskA := &Task{ + Weight: 10, + Fn: func() { + time.Sleep(time.Second) + }, + Name: "TaskA", + } + tasks := []*Task{taskA} + runner := newLocalRunner(2, 2) + runner.setTasks(tasks) + go runner.start() + time.Sleep(4 * time.Second) + runner.stop() +} + +func TestLoopCount(t *testing.T) { + taskA := &Task{ + Weight: 10, + Fn: func() { + time.Sleep(time.Second) + }, + Name: "TaskA", + } + tasks := []*Task{taskA} + runner := newLocalRunner(2, 2) + runner.loop = &Loop{loopCount: 4} + runner.setTasks(tasks) + go runner.start() + ticker := time.NewTicker(4 * time.Second) + defer ticker.Stop() + <-ticker.C + if !assert.Equal(t, runner.loop.loopCount, atomic.LoadInt64(&runner.loop.finishedCount)) { + t.Fail() + } +} diff --git a/hrp/internal/boomer/stats.go b/hrp/internal/boomer/stats.go new file mode 100644 index 00000000..b15c655d --- /dev/null +++ b/hrp/internal/boomer/stats.go @@ -0,0 +1,316 @@ +package boomer + +import ( + "time" + + "github.com/httprunner/httprunner/hrp/internal/json" +) + +type transaction struct { + name string + success bool + elapsedTime int64 + contentSize int64 +} + +type requestSuccess struct { + requestType string + name string + responseTime int64 + responseLength int64 +} + +type requestFailure struct { + requestType string + name string + responseTime int64 + errMsg string +} + +type requestStats struct { + entries map[string]*statsEntry + errors map[string]*statsError + total *statsEntry + startTime int64 + + transactionChan chan *transaction + transactionPassed int64 // accumulated number of passed transactions + transactionFailed int64 // accumulated number of failed transactions + + requestSuccessChan chan *requestSuccess + requestFailureChan chan *requestFailure +} + +func newRequestStats() (stats *requestStats) { + entries := make(map[string]*statsEntry) + errors := make(map[string]*statsError) + + stats = &requestStats{ + entries: entries, + errors: errors, + } + stats.transactionChan = make(chan *transaction, 100) + stats.requestSuccessChan = make(chan *requestSuccess, 100) + stats.requestFailureChan = make(chan *requestFailure, 100) + + stats.total = &statsEntry{ + Name: "Total", + Method: "", + } + stats.total.reset() + + return stats +} + +func (s *requestStats) logTransaction(name string, success bool, responseTime int64, contentLength int64) { + if success { + s.transactionPassed++ + } else { + s.transactionFailed++ + s.get(name, "transaction").logFailures() + } + s.get(name, "transaction").log(responseTime, contentLength) +} + +func (s *requestStats) logRequest(method, name string, responseTime int64, contentLength int64) { + s.total.log(responseTime, contentLength) + s.get(name, method).log(responseTime, contentLength) +} + +func (s *requestStats) logError(method, name, err string) { + s.total.logFailures() + s.get(name, method).logFailures() + + // store error in errors map + key := genMD5(method, name, err) + entry, ok := s.errors[key] + if !ok { + entry = &statsError{ + name: name, + method: method, + errMsg: err, + } + s.errors[key] = entry + } + entry.occured() +} + +func (s *requestStats) get(name string, method string) (entry *statsEntry) { + entry, ok := s.entries[name+method] + if !ok { + newEntry := &statsEntry{ + Name: name, + Method: method, + NumReqsPerSec: make(map[int64]int64), + NumFailPerSec: make(map[int64]int64), + ResponseTimes: make(map[int64]int64), + } + s.entries[name+method] = newEntry + return newEntry + } + return entry +} + +func (s *requestStats) clearAll() { + s.total = &statsEntry{ + Name: "Total", + Method: "", + } + s.total.reset() + s.transactionPassed = 0 + s.transactionFailed = 0 + s.entries = make(map[string]*statsEntry) + s.errors = make(map[string]*statsError) + s.startTime = time.Now().Unix() +} + +func (s *requestStats) serializeStats() []interface{} { + entries := make([]interface{}, 0, len(s.entries)) + for _, v := range s.entries { + if !(v.NumRequests == 0 && v.NumFailures == 0) { + entries = append(entries, v.getStrippedReport()) + } + } + return entries +} + +func (s *requestStats) serializeErrors() map[string]map[string]interface{} { + errors := make(map[string]map[string]interface{}) + for k, v := range s.errors { + errors[k] = v.toMap() + } + return errors +} + +func (s *requestStats) collectReportData() map[string]interface{} { + data := make(map[string]interface{}) + data["transactions"] = map[string]int64{ + "passed": s.transactionPassed, + "failed": s.transactionFailed, + } + data["stats"] = s.serializeStats() + data["stats_total"] = s.total.serialize() + data["errors"] = s.serializeErrors() + s.errors = make(map[string]*statsError) + return data +} + +// statsEntry represents a single stats entry (name and method) +type statsEntry struct { + // Name (URL) of this stats entry + Name string `json:"name"` + // Method (GET, POST, PUT, etc.) + Method string `json:"method"` + // The number of requests made + NumRequests int64 `json:"num_requests"` + // Number of failed request + NumFailures int64 `json:"num_failures"` + // Total sum of the response times + TotalResponseTime int64 `json:"total_response_time"` + // Minimum response time + MinResponseTime int64 `json:"min_response_time"` + // Maximum response time + MaxResponseTime int64 `json:"max_response_time"` + // A {second => request_count} dict that holds the number of requests made per second + NumReqsPerSec map[int64]int64 `json:"num_reqs_per_sec"` + // A (second => failure_count) dict that hold the number of failures per second + NumFailPerSec map[int64]int64 `json:"num_fail_per_sec"` + // A {response_time => count} dict that holds the response time distribution of all the requests + // The keys (the response time in ms) are rounded to store 1, 2, ... 9, 10, 20. .. 90, + // 100, 200 .. 900, 1000, 2000 ... 9000, in order to save memory. + // This dict is used to calculate the median and percentile response times. + ResponseTimes map[int64]int64 `json:"response_times"` + // The sum of the content length of all the requests for this entry + TotalContentLength int64 `json:"total_content_length"` + // Time of the first request for this entry + StartTime int64 `json:"start_time"` + // Time of the last request for this entry + LastRequestTimestamp int64 `json:"last_request_timestamp"` + // Boomer doesn't allow None response time for requests like locust. + // num_none_requests is added to keep compatible with locust. + NumNoneRequests int64 `json:"num_none_requests"` +} + +func (s *statsEntry) reset() { + s.StartTime = time.Now().Unix() + s.NumRequests = 0 + s.NumFailures = 0 + s.TotalResponseTime = 0 + s.ResponseTimes = make(map[int64]int64) + s.MinResponseTime = 0 + s.MaxResponseTime = 0 + s.LastRequestTimestamp = time.Now().Unix() + s.NumReqsPerSec = make(map[int64]int64) + s.NumFailPerSec = make(map[int64]int64) + s.TotalContentLength = 0 +} + +func (s *statsEntry) log(responseTime int64, contentLength int64) { + s.NumRequests++ + + s.logTimeOfRequest() + s.logResponseTime(responseTime) + + s.TotalContentLength += contentLength +} + +func (s *statsEntry) logTimeOfRequest() { + key := time.Now().Unix() + _, ok := s.NumReqsPerSec[key] + if !ok { + s.NumReqsPerSec[key] = 1 + } else { + s.NumReqsPerSec[key]++ + } + + s.LastRequestTimestamp = key +} + +func (s *statsEntry) logResponseTime(responseTime int64) { + s.TotalResponseTime += responseTime + + if s.MinResponseTime == 0 { + s.MinResponseTime = responseTime + } + + if responseTime < s.MinResponseTime { + s.MinResponseTime = responseTime + } + + if responseTime > s.MaxResponseTime { + s.MaxResponseTime = responseTime + } + + var roundedResponseTime int64 + + // to avoid too much data that has to be transferred to the master node when + // running in distributed mode, we save the response time rounded in a dict + // so that 147 becomes 150, 3432 becomes 3400 and 58760 becomes 59000 + // see also locust's stats.py + if responseTime < 100 { + roundedResponseTime = responseTime + } else if responseTime < 1000 { + roundedResponseTime = int64(round(float64(responseTime), .5, -1)) + } else if responseTime < 10000 { + roundedResponseTime = int64(round(float64(responseTime), .5, -2)) + } else { + roundedResponseTime = int64(round(float64(responseTime), .5, -3)) + } + + _, ok := s.ResponseTimes[roundedResponseTime] + if !ok { + s.ResponseTimes[roundedResponseTime] = 1 + } else { + s.ResponseTimes[roundedResponseTime]++ + } +} + +func (s *statsEntry) logFailures() { + s.NumFailures++ + key := time.Now().Unix() + _, ok := s.NumFailPerSec[key] + if !ok { + s.NumFailPerSec[key] = 1 + } else { + s.NumFailPerSec[key]++ + } +} + +func (s *statsEntry) serialize() map[string]interface{} { + var result map[string]interface{} + val, err := json.Marshal(s) + if err != nil { + return nil + } + err = json.Unmarshal(val, &result) + if err != nil { + return nil + } + return result +} + +func (s *statsEntry) getStrippedReport() map[string]interface{} { + report := s.serialize() + s.reset() + return report +} + +type statsError struct { + name string + method string + errMsg string + occurrences int64 +} + +func (err *statsError) occured() { + err.occurrences++ +} + +func (err *statsError) toMap() map[string]interface{} { + m := make(map[string]interface{}) + m["method"] = err.method + m["name"] = err.name + m["error"] = err.errMsg + m["occurrences"] = err.occurrences + return m +} diff --git a/hrp/internal/boomer/stats_test.go b/hrp/internal/boomer/stats_test.go new file mode 100644 index 00000000..afe0b41e --- /dev/null +++ b/hrp/internal/boomer/stats_test.go @@ -0,0 +1,216 @@ +package boomer + +import ( + "testing" +) + +func TestLogRequest(t *testing.T) { + newStats := newRequestStats() + newStats.logRequest("http", "success", 2, 30) + newStats.logRequest("http", "success", 3, 40) + newStats.logRequest("http", "success", 2, 40) + newStats.logRequest("http", "success", 1, 20) + entry := newStats.get("success", "http") + + if entry.NumRequests != 4 { + t.Error("numRequests is wrong, expected: 4, got:", entry.NumRequests) + } + if entry.MinResponseTime != 1 { + t.Error("minResponseTime is wrong, expected: 1, got:", entry.MinResponseTime) + } + if entry.MaxResponseTime != 3 { + t.Error("maxResponseTime is wrong, expected: 3, got:", entry.MaxResponseTime) + } + if entry.TotalResponseTime != 8 { + t.Error("totalResponseTime is wrong, expected: 8, got:", entry.TotalResponseTime) + } + if entry.TotalContentLength != 130 { + t.Error("totalContentLength is wrong, expected: 130, got:", entry.TotalContentLength) + } + + // check newStats.total + if newStats.total.NumRequests != 4 { + t.Error("newStats.total.numRequests is wrong, expected: 4, got:", newStats.total.NumRequests) + } + if newStats.total.MinResponseTime != 1 { + t.Error("newStats.total.minResponseTime is wrong, expected: 1, got:", newStats.total.MinResponseTime) + } + if newStats.total.MaxResponseTime != 3 { + t.Error("newStats.total.maxResponseTime is wrong, expected: 3, got:", newStats.total.MaxResponseTime) + } + if newStats.total.TotalResponseTime != 8 { + t.Error("newStats.total.totalResponseTime is wrong, expected: 8, got:", newStats.total.TotalResponseTime) + } + if newStats.total.TotalContentLength != 130 { + t.Error("newStats.total.totalContentLength is wrong, expected: 130, got:", newStats.total.TotalContentLength) + } +} + +func BenchmarkLogRequest(b *testing.B) { + newStats := newRequestStats() + for i := 0; i < b.N; i++ { + newStats.logRequest("http", "success", 2, 30) + } +} + +func TestRoundedResponseTime(t *testing.T) { + newStats := newRequestStats() + newStats.logRequest("http", "success", 147, 1) + newStats.logRequest("http", "success", 3432, 1) + newStats.logRequest("http", "success", 58760, 1) + entry := newStats.get("success", "http") + responseTimes := entry.ResponseTimes + + if len(responseTimes) != 3 { + t.Error("len(responseTimes) is wrong, expected: 3, got:", len(responseTimes)) + } + + if val, ok := responseTimes[150]; !ok || val != 1 { + t.Error("Rounded response time should be", 150) + } + + if val, ok := responseTimes[3400]; !ok || val != 1 { + t.Error("Rounded response time should be", 3400) + } + + if val, ok := responseTimes[59000]; !ok || val != 1 { + t.Error("Rounded response time should be", 59000) + } +} + +func TestLogError(t *testing.T) { + newStats := newRequestStats() + newStats.logError("http", "failure", "500 error") + newStats.logError("http", "failure", "400 error") + newStats.logError("http", "failure", "400 error") + entry := newStats.get("failure", "http") + + if entry.NumFailures != 3 { + t.Error("numFailures is wrong, expected: 3, got:", entry.NumFailures) + } + + if newStats.total.NumFailures != 3 { + t.Error("newStats.total.numFailures is wrong, expected: 3, got:", newStats.total.NumFailures) + } + + // md5("httpfailure500 error") = 547c38e4e4742c1c581f9e2809ba4f55 + err500 := newStats.errors["547c38e4e4742c1c581f9e2809ba4f55"] + if err500.errMsg != "500 error" { + t.Error("Error message is wrong, expected: 500 error, got:", err500.errMsg) + } + if err500.occurrences != 1 { + t.Error("Error occurrences is wrong, expected: 1, got:", err500.occurrences) + } + + // md5("httpfailure400 error") = f391c310401ad8e10e929f2ee1a614e4 + err400 := newStats.errors["f391c310401ad8e10e929f2ee1a614e4"] + if err400.errMsg != "400 error" { + t.Error("Error message is wrong, expected: 400 error, got:", err400.errMsg) + } + if err400.occurrences != 2 { + t.Error("Error occurrences is wrong, expected: 2, got:", err400.occurrences) + } + +} + +func BenchmarkLogError(b *testing.B) { + newStats := newRequestStats() + for i := 0; i < b.N; i++ { + // LogError use md5 to calculate hash keys, it may slow down the only goroutine, + // which consumes both requestSuccessChannel and requestFailureChannel. + newStats.logError("http", "failure", "500 error") + } +} + +func TestClearAll(t *testing.T) { + newStats := newRequestStats() + newStats.logRequest("http", "success", 1, 20) + newStats.clearAll() + + if newStats.total.NumRequests != 0 { + t.Error("After clearAll(), newStats.total.numRequests is wrong, expected: 0, got:", newStats.total.NumRequests) + } +} + +func TestClearAllByChannel(t *testing.T) { + newStats := newRequestStats() + newStats.logRequest("http", "success", 1, 20) + newStats.clearAll() + + if newStats.total.NumRequests != 0 { + t.Error("After clearAll(), newStats.total.numRequests is wrong, expected: 0, got:", newStats.total.NumRequests) + } +} + +func TestSerializeStats(t *testing.T) { + newStats := newRequestStats() + newStats.logRequest("http", "success", 1, 20) + + serialized := newStats.serializeStats() + if len(serialized) != 1 { + t.Error("The length of serialized results is wrong, expected: 1, got:", len(serialized)) + return + } + + first := serialized[0] + entry, err := deserializeStatsEntry(first) + if err != nil { + t.Fail() + } + + if entry.Name != "success" { + t.Error("The name is wrong, expected:", "success", "got:", entry.Name) + } + if entry.Method != "http" { + t.Error("The method is wrong, expected:", "http", "got:", entry.Method) + } + if entry.NumRequests != int64(1) { + t.Error("The num_requests is wrong, expected:", 1, "got:", entry.NumRequests) + } + if entry.NumFailures != int64(0) { + t.Error("The num_failures is wrong, expected:", 0, "got:", entry.NumFailures) + } +} + +func TestSerializeErrors(t *testing.T) { + newStats := newRequestStats() + newStats.logError("http", "failure", "500 error") + newStats.logError("http", "failure", "400 error") + newStats.logError("http", "failure", "400 error") + serialized := newStats.serializeErrors() + + if len(serialized) != 2 { + t.Error("The length of serialized results is wrong, expected: 2, got:", len(serialized)) + return + } + + for key, value := range serialized { + if key == "f391c310401ad8e10e929f2ee1a614e4" { + err := value["error"].(string) + if err != "400 error" { + t.Error("expected: 400 error, got:", err) + } + occurrences := value["occurrences"].(int64) + if occurrences != int64(2) { + t.Error("expected: 2, got:", occurrences) + } + } + } +} + +func TestCollectReportData(t *testing.T) { + newStats := newRequestStats() + newStats.logRequest("http", "success", 2, 30) + newStats.logError("http", "failure", "500 error") + result := newStats.collectReportData() + + if _, ok := result["stats"]; !ok { + t.Error("Key stats not found") + } + if _, ok := result["stats_total"]; !ok { + t.Error("Key stats not found") + } + if _, ok := result["errors"]; !ok { + t.Error("Key stats not found") + } +} diff --git a/hrp/internal/boomer/task.go b/hrp/internal/boomer/task.go new file mode 100644 index 00000000..e913d093 --- /dev/null +++ b/hrp/internal/boomer/task.go @@ -0,0 +1,13 @@ +package boomer + +// Task is like the "Locust object" in locust, the python version. +// When boomer receives a start message from master, it will spawn several goroutines to run Task.Fn. +// But users can keep some information in the python version, they can't do the same things in boomer. +// Because Task.Fn is a pure function. +type Task struct { + // The weight is used to distribute goroutines over multiple tasks. + Weight int + // Fn is called by the goroutines allocated to this task, in a loop. + Fn func() + Name string +} diff --git a/hrp/internal/boomer/ulimit.go b/hrp/internal/boomer/ulimit.go new file mode 100644 index 00000000..b83585ea --- /dev/null +++ b/hrp/internal/boomer/ulimit.go @@ -0,0 +1,32 @@ +// +build !windows + +package boomer + +import ( + "syscall" + + "github.com/rs/zerolog/log" +) + +// set resource limit +// ulimit -n 10240 +func SetUlimit(limit uint64) { + var rLimit syscall.Rlimit + err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit) + if err != nil { + log.Error().Err(err).Msg("get ulimit failed") + return + } + log.Info().Uint64("limit", rLimit.Cur).Msg("get current ulimit") + if rLimit.Cur >= limit { + return + } + + rLimit.Cur = limit + log.Info().Uint64("limit", rLimit.Cur).Msg("set current ulimit") + err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit) + if err != nil { + log.Error().Err(err).Msg("set ulimit failed") + return + } +} diff --git a/hrp/internal/boomer/ulimit_windows.go b/hrp/internal/boomer/ulimit_windows.go new file mode 100644 index 00000000..76ca69fc --- /dev/null +++ b/hrp/internal/boomer/ulimit_windows.go @@ -0,0 +1,12 @@ +// +build windows + +package boomer + +import ( + "github.com/rs/zerolog/log" +) + +// set resource limit +func SetUlimit(limit uint64) { + log.Warn().Msg("windows does not support setting ulimit") +} diff --git a/hrp/internal/boomer/utils.go b/hrp/internal/boomer/utils.go new file mode 100644 index 00000000..9a6f3fef --- /dev/null +++ b/hrp/internal/boomer/utils.go @@ -0,0 +1,77 @@ +package boomer + +import ( + "crypto/md5" + "fmt" + "io" + "math" + "os" + "runtime/pprof" + "time" + + "github.com/rs/zerolog/log" +) + +func round(val float64, roundOn float64, places int) (newVal float64) { + var round float64 + pow := math.Pow(10, float64(places)) + digit := pow * val + _, div := math.Modf(digit) + if div >= roundOn { + round = math.Ceil(digit) + } else { + round = math.Floor(digit) + } + newVal = round / pow + return +} + +// genMD5 returns the md5 hash of strings. +func genMD5(slice ...string) string { + h := md5.New() + for _, v := range slice { + io.WriteString(h, v) + } + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// startMemoryProfile starts memory profiling and save the results in file. +func startMemoryProfile(file string, duration time.Duration) (err error) { + f, err := os.Create(file) + if err != nil { + return err + } + + log.Info().Dur("duration", duration).Msg("Start memory profiling") + time.AfterFunc(duration, func() { + err := pprof.WriteHeapProfile(f) + if err != nil { + log.Error().Err(err).Msg("failed to write memory profile") + } + f.Close() + log.Info().Dur("duration", duration).Msg("Stop memory profiling") + }) + return nil +} + +// startCPUProfile starts cpu profiling and save the results in file. +func startCPUProfile(file string, duration time.Duration) (err error) { + f, err := os.Create(file) + if err != nil { + return err + } + + log.Info().Dur("duration", duration).Msg("Start CPU profiling") + err = pprof.StartCPUProfile(f) + if err != nil { + f.Close() + return err + } + + time.AfterFunc(duration, func() { + pprof.StopCPUProfile() + f.Close() + log.Info().Dur("duration", duration).Msg("Stop CPU profiling") + }) + return nil +} diff --git a/hrp/internal/boomer/utils_test.go b/hrp/internal/boomer/utils_test.go new file mode 100644 index 00000000..c56d1457 --- /dev/null +++ b/hrp/internal/boomer/utils_test.go @@ -0,0 +1,73 @@ +package boomer + +import ( + "os" + "testing" + "time" +) + +func TestRound(t *testing.T) { + if int(round(float64(147.5002), .5, -1)) != 150 { + t.Error("147.5002 should be rounded to 150") + } + + if int(round(float64(3432.5002), .5, -2)) != 3400 { + t.Error("3432.5002 should be rounded to 3400") + } + + roundOne := round(float64(58760.5002), .5, -3) + roundTwo := round(float64(58960.6003), .5, -3) + if roundOne != roundTwo { + t.Error("round(58760.5002) should be equal to round(58960.6003)") + } + + roundOne = round(float64(58360.5002), .5, -3) + roundTwo = round(float64(58460.6003), .5, -3) + if roundOne != roundTwo { + t.Error("round(58360.5002) should be equal to round(58460.6003)") + } + + roundOne = round(float64(58360), .5, -3) + roundTwo = round(float64(58460), .5, -3) + if roundOne != roundTwo { + t.Error("round(58360) should be equal to round(58460)") + } + +} + +func TestGenMD5(t *testing.T) { + hashValue := genMD5("Hello", "World!") + if hashValue != "06e0e6637d27b2622ab52022db713ce2" { + t.Error("Expected: 06e0e6637d27b2622ab52022db713ce2, Got: ", hashValue) + } +} + +func TestStartMemoryProfile(t *testing.T) { + if _, err := os.Stat("mem.pprof"); os.IsExist(err) { + os.Remove("mem.pprof") + } + if err := startMemoryProfile("mem.pprof", 2*time.Second); err != nil { + t.Error("Error starting memory profiling") + } + time.Sleep(2100 * time.Millisecond) + if _, err := os.Stat("mem.pprof"); os.IsNotExist(err) { + t.Error("File mem.pprof is not generated") + } else { + os.Remove("mem.pprof") + } +} + +func TestStartCPUProfile(t *testing.T) { + if _, err := os.Stat("cpu.pprof"); os.IsExist(err) { + os.Remove("cpu.pprof") + } + if err := startCPUProfile("cpu.pprof", 2*time.Second); err != nil { + t.Error("Error starting cpu profiling") + } + time.Sleep(2100 * time.Millisecond) + if _, err := os.Stat("cpu.pprof"); os.IsNotExist(err) { + t.Error("File cpu.pprof is not generated") + } else { + os.Remove("cpu.pprof") + } +} diff --git a/hrp/internal/builtin/assertion.go b/hrp/internal/builtin/assertion.go new file mode 100644 index 00000000..01eb3157 --- /dev/null +++ b/hrp/internal/builtin/assertion.go @@ -0,0 +1,208 @@ +package builtin + +import ( + "fmt" + "reflect" + "strings" + + "github.com/stretchr/testify/assert" +) + +var Assertions = map[string]func(t assert.TestingT, actual interface{}, expected interface{}, msgAndArgs ...interface{}) bool{ + "eq": assert.EqualValues, + "equals": assert.EqualValues, + "equal": assert.EqualValues, + "lt": assert.Less, + "less_than": assert.Less, + "le": assert.LessOrEqual, + "less_or_equals": assert.LessOrEqual, + "gt": assert.Greater, + "greater_than": assert.Greater, + "ge": assert.GreaterOrEqual, + "greater_or_equals": assert.GreaterOrEqual, + "ne": assert.NotEqual, + "not_equal": assert.NotEqual, + "contains": assert.Contains, + "type_match": assert.IsType, + // custom assertions + "startswith": StartsWith, + "endswith": EndsWith, + "len_eq": EqualLength, + "length_equals": EqualLength, + "length_equal": EqualLength, + "len_lt": LessThanLength, + "count_lt": LessThanLength, + "length_less_than": LessThanLength, + "len_le": LessOrEqualsLength, + "count_le": LessOrEqualsLength, + "length_less_or_equals": LessOrEqualsLength, + "len_gt": GreaterThanLength, + "count_gt": GreaterThanLength, + "length_greater_than": GreaterThanLength, + "len_ge": GreaterOrEqualsLength, + "count_ge": GreaterOrEqualsLength, + "length_greater_or_equals": GreaterOrEqualsLength, + "contained_by": ContainedBy, + "str_eq": StringEqual, + "string_equals": StringEqual, + "regex_match": RegexMatch, +} + +// StartsWith check if string starts with substring +func StartsWith(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + if !assert.IsType(t, "string", actual, fmt.Sprintf("actual is %v", actual)) { + return false + } + if !assert.IsType(t, "string", expected, fmt.Sprintf("expected is %v", expected)) { + return false + } + actualString := actual.(string) + expectedString := expected.(string) + return assert.True(t, strings.HasPrefix(actualString, expectedString), msgAndArgs...) +} + +// EndsWith check if string ends with substring +func EndsWith(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + if !assert.IsType(t, "string", actual, fmt.Sprintf("actual is %v", actual)) { + return false + } + if !assert.IsType(t, "string", expected, fmt.Sprintf("expected is %v", expected)) { + return false + } + actualString := actual.(string) + expectedString := expected.(string) + return assert.True(t, strings.HasSuffix(actualString, expectedString), msgAndArgs...) +} + +func EqualLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + length, err := convertInt(expected) + if err != nil { + return assert.Fail(t, fmt.Sprintf("expected type is not int, got %#v", expected), msgAndArgs...) + } + + return assert.Len(t, actual, length, msgAndArgs...) +} + +func GreaterThanLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + length, err := convertInt(expected) + if err != nil { + return assert.Fail(t, fmt.Sprintf("expected type is not int, got %#v", expected), msgAndArgs...) + } + ok, l := getLen(actual) + if !ok { + return assert.Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", actual), msgAndArgs...) + } + if l <= length { + return assert.Fail(t, fmt.Sprintf("\"%s\" should be more than %d item(s), but has %d", actual, length, l), msgAndArgs...) + } + return true +} + +func GreaterOrEqualsLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + length, err := convertInt(expected) + if err != nil { + return assert.Fail(t, fmt.Sprintf("expected type is not int, got %#v", expected), msgAndArgs...) + } + ok, l := getLen(actual) + if !ok { + return assert.Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", actual), msgAndArgs...) + } + if l < length { + return assert.Fail(t, fmt.Sprintf("\"%s\" should be no less than %d item(s), but has %d", actual, length, l), msgAndArgs...) + } + return true +} + +func LessThanLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + length, err := convertInt(expected) + if err != nil { + return assert.Fail(t, fmt.Sprintf("expected type is not int, got %#v", expected), msgAndArgs...) + } + ok, l := getLen(actual) + if !ok { + return assert.Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", actual), msgAndArgs...) + } + if l >= length { + return assert.Fail(t, fmt.Sprintf("\"%s\" should be less than %d item(s), but has %d", actual, length, l), msgAndArgs...) + } + return true +} + +func LessOrEqualsLength(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + length, err := convertInt(expected) + if err != nil { + return assert.Fail(t, fmt.Sprintf("expected type is not int, got %#v", expected), msgAndArgs...) + } + ok, l := getLen(actual) + if !ok { + return assert.Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", actual), msgAndArgs...) + } + if l > length { + return assert.Fail(t, fmt.Sprintf("\"%s\" should be no more than %d item(s), but has %d", actual, length, l), msgAndArgs...) + } + return true +} + +// ContainedBy assert whether actual element contains expected element +func ContainedBy(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + return assert.Contains(t, expected, actual, msgAndArgs) +} + +func StringEqual(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + if !assert.IsType(t, "string", actual, msgAndArgs) { + return false + } + if !assert.IsType(t, "string", expected, msgAndArgs) { + return false + } + actualString := actual.(string) + expectedString := expected.(string) + return assert.True(t, strings.EqualFold(actualString, expectedString), msgAndArgs) +} + +func RegexMatch(t assert.TestingT, actual, expected interface{}, msgAndArgs ...interface{}) bool { + return assert.Regexp(t, expected, actual, msgAndArgs) +} + +func convertInt(value interface{}) (int, error) { + switch v := value.(type) { + case int: + return v, nil + case int8: + return int(v), nil + case int16: + return int(v), nil + case int32: + return int(v), nil + case int64: + return int(v), nil + case uint: + return int(v), nil + case uint8: + return int(v), nil + case uint16: + return int(v), nil + case uint32: + return int(v), nil + case uint64: + return int(v), nil + case float32: + return int(v), nil + case float64: + return int(v), nil + default: + return 0, fmt.Errorf("unsupported int convertion for %v(%T)", v, v) + } +} + +// getLen try to get length of object. +// return (false, 0) if impossible. +func getLen(x interface{}) (ok bool, length int) { + v := reflect.ValueOf(x) + defer func() { + if e := recover(); e != nil { + ok = false + } + }() + return true, v.Len() +} diff --git a/hrp/internal/builtin/assertion_test.go b/hrp/internal/builtin/assertion_test.go new file mode 100644 index 00000000..d919464e --- /dev/null +++ b/hrp/internal/builtin/assertion_test.go @@ -0,0 +1,191 @@ +package builtin + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStartsWith(t *testing.T) { + testData := []struct { + raw string + expected string + }{ + {"", ""}, + {"a", "a"}, + {"abc", "a"}, + {"abc", "ab"}, + } + + for _, data := range testData { + if !assert.True(t, StartsWith(t, data.raw, data.expected)) { + t.Fail() + } + } +} + +func TestEndsWith(t *testing.T) { + testData := []struct { + raw string + expected string + }{ + {"", ""}, + {"a", "a"}, + {"abc", "c"}, + {"abc", "bc"}, + } + + for _, data := range testData { + if !assert.True(t, EndsWith(t, data.raw, data.expected)) { + t.Fail() + } + } +} + +func TestEqualLength(t *testing.T) { + testData := []struct { + raw interface{} + expected int + }{ + {"", 0}, + {[]string{}, 0}, + {map[string]interface{}{}, 0}, + {"a", 1}, + {[]string{"a"}, 1}, + {map[string]interface{}{"a": 123}, 1}, + } + + for _, data := range testData { + if !assert.True(t, EqualLength(t, data.raw, data.expected)) { + t.Fail() + } + } +} + +func TestLessThanLength(t *testing.T) { + testData := []struct { + raw interface{} + expected int + }{ + {"", 1}, + {[]string{}, 1}, + {map[string]interface{}{}, 1}, + {"a", 2}, + {[]string{"a"}, 2}, + {map[string]interface{}{"a": 123}, 2}, + } + + for _, data := range testData { + if !assert.True(t, LessThanLength(t, data.raw, data.expected)) { + t.Fail() + } + } +} + +func TestLessOrEqualsLength(t *testing.T) { + testData := []struct { + raw interface{} + expected int + }{ + {"", 1}, + {[]string{}, 1}, + {map[string]interface{}{"A": 111}, 1}, + {"a", 1}, + {[]string{"a"}, 2}, + {map[string]interface{}{"a": 123}, 2}, + } + + for _, data := range testData { + if !assert.True(t, LessOrEqualsLength(t, data.raw, data.expected)) { + t.Fail() + } + } +} + +func TestGreaterThanLength(t *testing.T) { + testData := []struct { + raw interface{} + expected int + }{ + {"abcd", 3}, + {[]string{"a", "b", "c"}, 2}, + {map[string]interface{}{"a": 123, "b": 223, "c": 323}, 2}, + } + + for _, data := range testData { + if !assert.True(t, GreaterThanLength(t, data.raw, data.expected)) { + t.Fail() + } + } +} + +func TestGreaterOrEqualsLength(t *testing.T) { + testData := []struct { + raw interface{} + expected int + }{ + {"abcd", 3}, + {[]string{"w"}, 1}, + {map[string]interface{}{"A": 111}, 1}, + {"a", 1}, + {[]string{"a", "b", "c"}, 2}, + {map[string]interface{}{"a": 123, "b": 223, "c": 323}, 2}, + } + + for _, data := range testData { + if !assert.True(t, GreaterOrEqualsLength(t, data.raw, data.expected)) { + t.Fail() + } + } +} + +func TestContainedBy(t *testing.T) { + testData := []struct { + raw interface{} + expected interface{} + }{ + {"abcd", "abcdefg"}, + {"a", []string{"a", "b", "c"}}, + {"A", map[string]interface{}{"A": 111, "B": 222}}, + } + + for _, data := range testData { + if !assert.True(t, ContainedBy(t, data.raw, data.expected)) { + t.Fail() + } + } +} + +func TestStringEqual(t *testing.T) { + testData := []struct { + raw interface{} + expected interface{} + }{ + {"abcd", "abcd"}, + {"abcd", "ABCD"}, + {"ABcd", "abCD"}, + } + + for _, data := range testData { + if !assert.True(t, StringEqual(t, data.raw, data.expected)) { + t.Fail() + } + } +} + +func TestRegexMatch(t *testing.T) { + testData := []struct { + raw interface{} + expected interface{} + }{ + {"it's starting...", regexp.MustCompile("start")}, + {"it's not starting", "starting$"}, + } + + for _, data := range testData { + if !assert.True(t, RegexMatch(t, data.raw, data.expected)) { + t.Fail() + } + } +} diff --git a/hrp/internal/builtin/function.go b/hrp/internal/builtin/function.go new file mode 100644 index 00000000..f95d01c7 --- /dev/null +++ b/hrp/internal/builtin/function.go @@ -0,0 +1,260 @@ +package builtin + +import ( + "bytes" + "crypto/md5" + "encoding/csv" + "encoding/hex" + builtinJSON "encoding/json" + "errors" + "fmt" + "math" + "math/rand" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" + + "github.com/httprunner/httprunner/hrp/internal/json" +) + +var Functions = map[string]interface{}{ + "get_timestamp": getTimestamp, // call without arguments + "sleep": sleep, // call with one argument + "gen_random_string": genRandomString, // call with one argument + "max": math.Max, // call with two arguments + "md5": MD5, // call with one argument + "parameterize": loadFromCSV, + "P": loadFromCSV, +} + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +func getTimestamp() int64 { + return time.Now().UnixNano() / int64(time.Millisecond) +} + +func sleep(nSecs int) { + time.Sleep(time.Duration(nSecs) * time.Second) +} + +const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + +func genRandomString(n int) string { + lettersLen := len(letters) + b := make([]byte, n) + for i := range b { + b[i] = letters[rand.Intn(lettersLen)] + } + return string(b) +} + +func MD5(str string) string { + hasher := md5.New() + hasher.Write([]byte(str)) + return hex.EncodeToString(hasher.Sum(nil)) +} + +func loadFromCSV(path string) []map[string]interface{} { + path, err := filepath.Abs(path) + if err != nil { + log.Error().Str("path", path).Err(err).Msg("convert absolute path failed") + panic(err) + } + log.Info().Str("path", path).Msg("load csv file") + + file, err := os.ReadFile(path) + if err != nil { + log.Error().Err(err).Msg("load csv file failed") + panic(err) + } + r := csv.NewReader(strings.NewReader(string(file))) + content, err := r.ReadAll() + if err != nil { + log.Error().Err(err).Msg("parse csv file failed") + panic(err) + } + var result []map[string]interface{} + for i := 1; i < len(content); i++ { + row := make(map[string]interface{}) + for j := 0; j < len(content[i]); j++ { + row[content[0][j]] = content[i][j] + } + result = append(result, row) + } + return result +} + +func Dump2JSON(data interface{}, path string) error { + path, err := filepath.Abs(path) + if err != nil { + log.Error().Err(err).Msg("convert absolute path failed") + return err + } + log.Info().Str("path", path).Msg("dump data to json") + file, _ := json.MarshalIndent(data, "", " ") + err = os.WriteFile(path, file, 0644) + if err != nil { + log.Error().Err(err).Msg("dump json path failed") + return err + } + return nil +} + +func Dump2YAML(data interface{}, path string) error { + path, err := filepath.Abs(path) + if err != nil { + log.Error().Err(err).Msg("convert absolute path failed") + return err + } + log.Info().Str("path", path).Msg("dump data to yaml") + + // init yaml encoder + buffer := new(bytes.Buffer) + encoder := yaml.NewEncoder(buffer) + encoder.SetIndent(4) + + // encode + err = encoder.Encode(data) + if err != nil { + return err + } + + err = os.WriteFile(path, buffer.Bytes(), 0644) + if err != nil { + log.Error().Err(err).Msg("dump yaml path failed") + return err + } + return nil +} + +func FormatResponse(raw interface{}) interface{} { + formattedResponse := make(map[string]interface{}) + for key, value := range raw.(map[string]interface{}) { + // convert value to json + if key == "body" { + b, _ := json.MarshalIndent(&value, "", " ") + value = string(b) + } + formattedResponse[key] = value + } + return formattedResponse +} + +func ExecCommand(cmd *exec.Cmd, cwd string) error { + log.Info().Str("cmd", cmd.String()).Str("cwd", cwd).Msg("exec command") + cmd.Dir = cwd + output, err := cmd.CombinedOutput() + out := strings.TrimSpace(string(output)) + if err != nil { + log.Error().Err(err).Str("output", out).Msg("exec command failed") + } else if len(out) != 0 { + log.Info().Str("output", out).Msg("exec command success") + } + return err +} + +func CreateFolder(folderPath string) error { + log.Info().Str("path", folderPath).Msg("create folder") + err := os.MkdirAll(folderPath, os.ModePerm) + if err != nil { + log.Error().Err(err).Msg("create folder failed") + return err + } + return nil +} + +func CreateFile(filePath string, data string) error { + log.Info().Str("path", filePath).Msg("create file") + err := os.WriteFile(filePath, []byte(data), 0o644) + if err != nil { + log.Error().Err(err).Msg("create file failed") + return err + } + return nil +} + +// isFilePathExists returns true if path exists, whether path is file or dir +func isPathExists(path string) bool { + if _, err := os.Stat(path); os.IsNotExist(err) { + return false + } + return true +} + +// isFilePathExists returns true if path exists and path is file +func isFilePathExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + // path not exists + return false + } + + // path exists + if info.IsDir() { + // path is dir, not file + return false + } + return true +} + +func EnsureFolderExists(folderPath string) error { + if !isPathExists(folderPath) { + err := CreateFolder(folderPath) + return err + } else if isFilePathExists(folderPath) { + return fmt.Errorf("path %v should be directory", folderPath) + } + return nil +} + +func Contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func GetRandomNumber(min, max int) int { + if min > max { + return 0 + } + r := rand.Intn(max - min + 1) + return min + r +} + +func Interface2Float64(i interface{}) (float64, error) { + switch i.(type) { + case int: + return float64(i.(int)), nil + case int32: + return float64(i.(int32)), nil + case int64: + return float64(i.(int64)), nil + case float32: + return float64(i.(float32)), nil + case float64: + return i.(float64), nil + case string: + intVar, err := strconv.Atoi(i.(string)) + if err != nil { + return 0, err + } + return float64(intVar), err + } + // json.Number + value, ok := i.(builtinJSON.Number) + if ok { + return value.Float64() + } + return 0, errors.New("failed to convert interface to float64") +} diff --git a/hrp/internal/ga/client.go b/hrp/internal/ga/client.go new file mode 100644 index 00000000..923e8578 --- /dev/null +++ b/hrp/internal/ga/client.go @@ -0,0 +1,76 @@ +package ga + +import ( + "fmt" + "net/http" + "net/url" + "reflect" + "time" +) + +const ( + gaAPIDebugURL = "https://www.google-analytics.com/debug/collect" // used for debug + gaAPIURL = "https://www.google-analytics.com/collect" +) + +type GAClient struct { + TrackingID string `form:"tid"` // Tracking ID / Property ID, XX-XXXXXXX-X + ClientID string `form:"cid"` // Anonymous Client ID + Version string `form:"v"` // Version + httpClient *http.Client // http client session +} + +// NewGAClient creates a new GAClient object with the trackingID and clientID. +func NewGAClient(trackingID, clientID string) *GAClient { + return &GAClient{ + TrackingID: trackingID, + ClientID: clientID, + Version: "1", // constant v1 + httpClient: &http.Client{ + Timeout: 5 * time.Second, + }, + } +} + +// SendEvent sends one event to Google Analytics +func (g *GAClient) SendEvent(e IEvent) error { + var data url.Values + if event, ok := e.(UserTimingTracking); ok { + event.duration = time.Since(event.startTime) + data = event.ToUrlValues() + } else { + data = e.ToUrlValues() + } + + // append common params + data.Add("v", g.Version) + data.Add("tid", g.TrackingID) + data.Add("cid", g.ClientID) + + resp, err := g.httpClient.PostForm(gaAPIURL, data) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("response status: %d", resp.StatusCode) + } + return nil +} + +func structToUrlValues(i interface{}) (values url.Values) { + values = url.Values{} + iVal := reflect.ValueOf(i) + for i := 0; i < iVal.NumField(); i++ { + formTagName := iVal.Type().Field(i).Tag.Get("form") + if formTagName == "" { + continue + } + if iVal.Field(i).IsZero() { + continue + } + values.Set(formTagName, fmt.Sprint(iVal.Field(i))) + } + return +} diff --git a/hrp/internal/ga/client_test.go b/hrp/internal/ga/client_test.go new file mode 100644 index 00000000..d1c29a72 --- /dev/null +++ b/hrp/internal/ga/client_test.go @@ -0,0 +1,30 @@ +package ga + +import ( + "testing" +) + +func TestSendEvents(t *testing.T) { + event := EventTracking{ + Category: "unittest", + Action: "SendEvents", + Value: 123, + } + err := gaClient.SendEvent(event) + if err != nil { + t.Fatal(err) + } +} + +func TestStructToUrlValues(t *testing.T) { + event := EventTracking{ + Category: "unittest", + Action: "convert", + Label: "v0.3.0", + Value: 123, + } + val := structToUrlValues(event) + if val.Encode() != "ea=convert&ec=unittest&el=v0.3.0&ev=123" { + t.Fail() + } +} diff --git a/hrp/internal/ga/events.go b/hrp/internal/ga/events.go new file mode 100644 index 00000000..7661b17a --- /dev/null +++ b/hrp/internal/ga/events.go @@ -0,0 +1,71 @@ +package ga + +import ( + "fmt" + "net/url" + "time" + + "github.com/httprunner/httprunner/hrp/internal/version" +) + +type IEvent interface { + ToUrlValues() url.Values +} + +type EventTracking struct { + HitType string `form:"t"` // Event hit type = event + Category string `form:"ec"` // Required. Event Category. + Action string `form:"ea"` // Required. Event Action. + Label string `form:"el"` // Optional. Event label, used as version. + Value int `form:"ev"` // Optional. Event value, must be non-negative integer +} + +func (e EventTracking) StartTiming(variable string) UserTimingTracking { + return UserTimingTracking{ + HitType: "timing", + Category: e.Category, + Variable: variable, + Label: e.Label, + startTime: time.Now(), // starts the timer + } +} + +func (e EventTracking) ToUrlValues() url.Values { + e.HitType = "event" + e.Label = version.VERSION + return structToUrlValues(e) +} + +type UserTimingTracking struct { + HitType string `form:"t"` // Timing hit type + Category string `form:"utc"` // Required. user timing category. e.g. jsonLoader + Variable string `form:"utv"` // Required. timing variable. e.g. load + Duration string `form:"utt"` // Required. time took duration. + Label string `form:"utl"` // Optional. user timing label. e.g jQuery + startTime time.Time + duration time.Duration // time took duration +} + +func (e UserTimingTracking) ToUrlValues() url.Values { + e.HitType = "timing" + e.Label = version.VERSION + e.Duration = fmt.Sprintf("%d", int64(e.duration.Seconds()*1000)) + return structToUrlValues(e) +} + +type Exception struct { + HitType string `form:"t"` // Hit Type = exception + Description string `form:"exd"` // exception description. i.e. IOException + IsFatal string `form:"exf"` // if the exception was fatal + isFatal bool +} + +func (e Exception) ToUrlValues() url.Values { + e.HitType = "exception" + if e.isFatal { + e.IsFatal = "1" + } else { + e.IsFatal = "0" + } + return structToUrlValues(e) +} diff --git a/hrp/internal/ga/init.go b/hrp/internal/ga/init.go new file mode 100644 index 00000000..b759398d --- /dev/null +++ b/hrp/internal/ga/init.go @@ -0,0 +1,25 @@ +package ga + +import ( + "github.com/denisbrodbeck/machineid" + "github.com/google/uuid" +) + +const ( + trackingID = "UA-114587036-1" // Tracking ID for Google Analytics +) + +var gaClient *GAClient + +func init() { + clientID, err := machineid.ProtectedID("hrp") + if err != nil { + nodeUUID, _ := uuid.NewUUID() + clientID = nodeUUID.String() + } + gaClient = NewGAClient(trackingID, clientID) +} + +func SendEvent(e IEvent) error { + return gaClient.SendEvent(e) +} diff --git a/hrp/internal/har2case/README.md b/hrp/internal/har2case/README.md new file mode 100644 index 00000000..08c0b4dc --- /dev/null +++ b/hrp/internal/har2case/README.md @@ -0,0 +1,9 @@ +# 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 new file mode 100644 index 00000000..bbbb14d9 --- /dev/null +++ b/hrp/internal/har2case/core.go @@ -0,0 +1,352 @@ +package har2case + +import ( + "encoding/base64" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/hrp/internal/ga" + "github.com/httprunner/httprunner/hrp/internal/json" +) + +const ( + suffixJSON = ".json" + suffixYAML = ".yaml" +) + +func NewHAR(path string) *har { + return &har{ + path: path, + } +} + +type har struct { + path string + filterStr string + excludeStr string + outputDir string +} + +func (h *har) SetOutputDir(dir string) { + h.outputDir = dir +} + +func (h *har) GenJSON() (jsonPath string, err error) { + event := ga.EventTracking{ + Category: "ConvertTests", + Action: "hrp har2case --to-json", + } + // report start event + go ga.SendEvent(event) + // report running timing event + defer ga.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 := ga.EventTracking{ + Category: "ConvertTests", + Action: "hrp har2case --to-yaml", + } + // report start event + go ga.SendEvent(event) + // report running timing event + defer ga.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) { + fp, err := os.Open(h.path) + if err != nil { + return nil, fmt.Errorf("open: %w", err) + } + + data, err := io.ReadAll(fp) + fp.Close() + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + + har := &Har{} + err = json.Unmarshal(data, har) + if err != nil { + return nil, fmt.Errorf("json.Unmarshal error: %w", err) + } + + 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), + }, + } + 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 +} + +func (s *tStep) makeRequestMethod(entry *Entry) error { + s.Request.Method = 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.Hostname()+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) + 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) + 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{} + 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/har2case/core_test.go b/hrp/internal/har2case/core_test.go new file mode 100644 index 00000000..81813dc7 --- /dev/null +++ b/hrp/internal/har2case/core_test.go @@ -0,0 +1,122 @@ +package har2case + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/httprunner/httprunner/hrp" +) + +var ( + harPath = "../../../examples/hrp/har/demo.har" + harPath2 = "../../../examples/hrp/har/postman-echo.har" +) + +func TestGenJSON(t *testing.T) { + jsonPath, err := NewHAR(harPath).GenJSON() + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.NotEmpty(t, jsonPath) { + t.Fail() + } +} + +func TestGenYAML(t *testing.T) { + yamlPath, err := NewHAR(harPath2).GenYAML() + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.NotEmpty(t, yamlPath) { + t.Fail() + } +} + +func TestLoadHAR(t *testing.T) { + har := NewHAR(harPath) + h, err := har.load() + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, "GET", h.Log.Entries[0].Request.Method) { + t.Fail() + } + if !assert.Equal(t, "POST", h.Log.Entries[1].Request.Method) { + t.Fail() + } +} + +func TestMakeTestCase(t *testing.T) { + har := NewHAR(harPath) + tCase, err := har.makeTestCase() + if !assert.NoError(t, err) { + t.Fail() + } + + // make request method + if !assert.EqualValues(t, "GET", tCase.TestSteps[0].Request.Method) { + t.Fail() + } + if !assert.EqualValues(t, "POST", tCase.TestSteps[1].Request.Method) { + t.Fail() + } + + // make request url + if !assert.Equal(t, "https://postman-echo.com/get", tCase.TestSteps[0].Request.URL) { + t.Fail() + } + if !assert.Equal(t, "https://postman-echo.com/post", tCase.TestSteps[1].Request.URL) { + t.Fail() + } + + // make request params + if !assert.Equal(t, "HDnY8", tCase.TestSteps[0].Request.Params["foo1"]) { + t.Fail() + } + + // make request cookies + if !assert.NotEmpty(t, tCase.TestSteps[1].Request.Cookies["sails.sid"]) { + t.Fail() + } + + // make request headers + if !assert.Equal(t, "HttpRunnerPlus", tCase.TestSteps[0].Request.Headers["User-Agent"]) { + t.Fail() + } + if !assert.Equal(t, "postman-echo.com", tCase.TestSteps[0].Request.Headers["Host"]) { + t.Fail() + } + + // make request data + if !assert.Equal(t, nil, tCase.TestSteps[0].Request.Body) { + t.Fail() + } + if !assert.Equal(t, map[string]interface{}{"foo1": "HDnY8", "foo2": 12.3}, tCase.TestSteps[1].Request.Body) { + t.Fail() + } + if !assert.Equal(t, "foo1=HDnY8&foo2=12.3", tCase.TestSteps[2].Request.Body) { + t.Fail() + } + + // make validators + validator, ok := tCase.TestSteps[0].Validators[0].(hrp.Validator) + if !ok || !assert.Equal(t, "status_code", validator.Check) { + t.Fail() + } + validator, ok = tCase.TestSteps[0].Validators[1].(hrp.Validator) + if !ok || !assert.Equal(t, "headers.\"Content-Type\"", validator.Check) { + t.Fail() + } + validator, ok = tCase.TestSteps[0].Validators[2].(hrp.Validator) + if !ok || !assert.Equal(t, "body.url", validator.Check) { + t.Fail() + } +} + +func TestGetFilenameWithoutExtension(t *testing.T) { + filename := getFilenameWithoutExtension("../../../examples/hrp/har/postman-echo.har") + if !assert.Equal(t, "postman-echo", filename) { + t.Fail() + } +} diff --git a/hrp/internal/har2case/har.go b/hrp/internal/har2case/har.go new file mode 100644 index 00000000..6b98839a --- /dev/null +++ b/hrp/internal/har2case/har.go @@ -0,0 +1,340 @@ +package har2case + +import "time" + +/* +HTTP Archive (HAR) format +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 { + Log Log `json:"log"` +} + +// Log represents the root of the exported data. This object MUST be present and its name MUST be "log". +type Log struct { + // The object contains the following name/value pairs: + + // Required. Version number of the format. + Version string `json:"version"` + // Required. An object of type creator that contains the name and version + // information of the log creator application. + Creator Creator `json:"creator"` + // Optional. An object of type browser that contains the name and version + // information of the user agent. + Browser Browser `json:"browser"` + // Optional. An array of objects of type page, each representing one exported + // (tracked) page. Leave out this field if the application does not support + // grouping by pages. + Pages []Page `json:"pages,omitempty"` + // Required. An array of objects of type entry, each representing one + // exported (tracked) HTTP request. + Entries []Entry `json:"entries"` + // Optional. A comment provided by the user or the application. Sorting + // entries by startedDateTime (starting from the oldest) is preferred way how + // to export data since it can make importing faster. However the reader + // application should always make sure the array is sorted (if required for + // the import). + Comment string `json:"comment"` +} + +// Creator contains information about the log creator application +type Creator struct { + // Required. The name of the application that created the log. + Name string `json:"name"` + // Required. The version number of the application that created the log. + Version string `json:"version"` + // Optional. A comment provided by the user or the application. + Comment string `json:"comment,omitempty"` +} + +// Browser that created the log +type Browser struct { + // Required. The name of the browser that created the log. + Name string `json:"name"` + // Required. The version number of the browser that created the log. + Version string `json:"version"` + // Optional. A comment provided by the user or the browser. + Comment string `json:"comment"` +} + +// Page object for every exported web page and one object for every HTTP request. +// In case when an HTTP trace tool isn't able to group requests by a page, +// the object is empty and individual requests doesn't have a parent page. +type Page struct { + /* There is one object for every exported web page and one + object for every HTTP request. In case when an HTTP trace tool isn't able to + group requests by a page, the object is empty and individual + requests doesn't have a parent page. + */ + + // Date and time stamp for the beginning of the page load + // (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.45+01:00). + StartedDateTime string `json:"startedDateTime"` + // Unique identifier of a page within the . Entries use it to refer the parent page. + ID string `json:"id"` + // Page title. + Title string `json:"title"` + // Detailed timing info about page load. + PageTiming PageTiming `json:"pageTiming"` + // (new in 1.2) A comment provided by the user or the application. + Comment string `json:"comment,omitempty"` +} + +// PageTiming describes timings for various events (states) fired during the page load. +// All times are specified in milliseconds. If a time info is not available appropriate field is set to -1. +type PageTiming struct { + // Content of the page loaded. Number of milliseconds since page load started + // (page.startedDateTime). Use -1 if the timing does not apply to the current + // request. + // Depeding on the browser, onContentLoad property represents DOMContentLoad + // event or document.readyState == interactive. + OnContentLoad int `json:"onContentLoad"` + // Page is loaded (onLoad event fired). Number of milliseconds since page + // load started (page.startedDateTime). Use -1 if the timing does not apply + // to the current request. + OnLoad int `json:"onLoad"` + // (new in 1.2) A comment provided by the user or the application. + Comment string `json:"comment"` +} + +// Entry is a unique, optional Reference to the parent page. +// Leave out this field if the application does not support grouping by pages. +type Entry struct { + Pageref string `json:"pageref,omitempty"` + // Date and time stamp of the request start + // (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD). + StartedDateTime string `json:"startedDateTime"` + // Total elapsed time of the request in milliseconds. This is the sum of all + // timings available in the timings object (i.e. not including -1 values) . + Time float32 `json:"time"` + // Detailed info about the request. + Request Request `json:"request"` + // Detailed info about the response. + Response Response `json:"response"` + // Info about cache usage. + Cache Cache `json:"cache"` + // Detailed timing info about request/response round trip. + PageTimings PageTimings `json:"pageTimings"` + // optional (new in 1.2) IP address of the server that was connected + // (result of DNS resolution). + ServerIPAddress string `json:"serverIPAddress,omitempty"` + // optional (new in 1.2) Unique ID of the parent TCP/IP connection, can be + // the client port number. Note that a port number doesn't have to be unique + // identifier in cases where the port is shared for more connections. If the + // port isn't available for the application, any other unique connection ID + // can be used instead (e.g. connection index). Leave out this field if the + // application doesn't support this info. + Connection string `json:"connection,omitempty"` + // (new in 1.2) A comment provided by the user or the application. + Comment string `json:"comment,omitempty"` +} + +// Request contains detailed info about performed request. +type Request struct { + // Request method (GET, POST, ...). + Method string `json:"method"` + // Absolute URL of the request (fragments are not included). + URL string `json:"url"` + // Request HTTP Version. + HTTPVersion string `json:"httpVersion"` + // List of cookie objects. + Cookies []Cookie `json:"cookies"` + // List of header objects. + Headers []NVP `json:"headers"` + // List of query parameter objects. + QueryString []NVP `json:"queryString"` + // Posted data. + PostData PostData `json:"postData"` + // Total number of bytes from the start of the HTTP request message until + // (and including) the double CRLF before the body. Set to -1 if the info + // is not available. + HeaderSize int `json:"headerSize"` + // Size of the request body (POST data payload) in bytes. Set to -1 if the + // info is not available. + BodySize int `json:"bodySize"` + // (new in 1.2) A comment provided by the user or the application. + Comment string `json:"comment"` +} + +// Response contains detailed info about the response. +type Response struct { + // Response status. + Status int `json:"status"` + // Response status description. + StatusText string `json:"statusText"` + // Response HTTP Version. + HTTPVersion string `json:"httpVersion"` + // List of cookie objects. + Cookies []Cookie `json:"cookies"` + // List of header objects. + Headers []NVP `json:"headers"` + // Details about the response body. + Content Content `json:"content"` + // Redirection target URL from the Location response header. + RedirectURL string `json:"redirectURL"` + // Total number of bytes from the start of the HTTP response message until + // (and including) the double CRLF before the body. Set to -1 if the info is + // not available. + // The size of received response-headers is computed only from headers that + // are really received from the server. Additional headers appended by the + // browser are not included in this number, but they appear in the list of + // header objects. + HeadersSize int `json:"headersSize"` + // Size of the received response body in bytes. Set to zero in case of + // responses coming from the cache (304). Set to -1 if the info is not + // available. + BodySize int `json:"bodySize"` + // optional (new in 1.2) A comment provided by the user or the application. + Comment string `json:"comment,omitempty"` +} + +// Cookie contains list of all cookies (used in and objects). +type Cookie struct { + // The name of the cookie. + Name string `json:"name"` + // The cookie value. + Value string `json:"value"` + // optional The path pertaining to the cookie. + Path string `json:"path,omitempty"` + // optional The host of the cookie. + Domain string `json:"domain,omitempty"` + // optional Cookie expiration time. + // (ISO 8601 YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.123+02:00). + Expires string `json:"expires,omitempty"` + // optional Set to true if the cookie is HTTP only, false otherwise. + HTTPOnly bool `json:"httpOnly,omitempty"` + // optional (new in 1.2) True if the cookie was transmitted over ssl, false + // otherwise. + Secure bool `json:"secure,omitempty"` + // optional (new in 1.2) A comment provided by the user or the application. + Comment bool `json:"comment,omitempty"` +} + +// NVP is simply a name/value pair with a comment +type NVP struct { + Name string `json:"name"` + Value string `json:"value"` + Comment string `json:"comment,omitempty"` +} + +// PostData describes posted data, if any (embedded in object). +type PostData struct { + // Mime type of posted data. + MimeType string `json:"mimeType"` + // List of posted parameters (in case of URL encoded parameters). + Params []PostParam `json:"params"` + // Plain text posted data + Text string `json:"text"` + // optional (new in 1.2) A comment provided by the user or the + // application. + Comment string `json:"comment,omitempty"` +} + +// PostParam is a list of posted parameters, if any (embedded in object). +type PostParam struct { + // name of a posted parameter. + Name string `json:"name"` + // optional value of a posted parameter or content of a posted file. + Value string `json:"value,omitempty"` + // optional name of a posted file. + FileName string `json:"fileName,omitempty"` + // optional content type of a posted file. + ContentType string `json:"contentType,omitempty"` + // optional (new in 1.2) A comment provided by the user or the application. + Comment string `json:"comment,omitempty"` +} + +// Content describes details about response content (embedded in object). +type Content struct { + // Length of the returned content in bytes. Should be equal to + // response.bodySize if there is no compression and bigger when the content + // has been compressed. + Size int `json:"size"` + // optional Number of bytes saved. Leave out this field if the information + // is not available. + Compression int `json:"compression,omitempty"` + // MIME type of the response text (value of the Content-Type response + // header). The charset attribute of the MIME type is included (if + // available). + MimeType string `json:"mimeType"` + // optional Response body sent from the server or loaded from the browser + // cache. This field is populated with textual content only. The text field + // is either HTTP decoded text or a encoded (e.g. "base64") representation of + // the response body. Leave out this field if the information is not + // available. + Text string `json:"text,omitempty"` + // optional (new in 1.2) Encoding used for response text field e.g + // "base64". Leave out this field if the text field is HTTP decoded + // (decompressed & unchunked), than trans-coded from its original character + // set into UTF-8. + Encoding string `json:"encoding,omitempty"` + // optional (new in 1.2) A comment provided by the user or the application. + Comment string `json:"comment,omitempty"` +} + +// Cache contains info about a request coming from browser cache. +type Cache struct { + // optional State of a cache entry before the request. Leave out this field + // if the information is not available. + BeforeRequest CacheObject `json:"beforeRequest,omitempty"` + // optional State of a cache entry after the request. Leave out this field if + // the information is not available. + AfterRequest CacheObject `json:"afterRequest,omitempty"` + // optional (new in 1.2) A comment provided by the user or the application. + Comment string `json:"comment,omitempty"` +} + +// CacheObject is used by both beforeRequest and afterRequest +type CacheObject struct { + // optional - Expiration time of the cache entry. + Expires string `json:"expires,omitempty"` + // The last time the cache entry was opened. + LastAccess string `json:"lastAccess"` + // Etag + ETag string `json:"eTag"` + // The number of times the cache entry has been opened. + HitCount int `json:"hitCount"` + // optional (new in 1.2) A comment provided by the user or the application. + Comment string `json:"comment,omitempty"` +} + +// PageTimings describes various phases within request-response round trip. +// All times are specified in milliseconds. +type PageTimings struct { + Blocked int `json:"blocked,omitempty"` + // optional - Time spent in a queue waiting for a network connection. Use -1 + // if the timing does not apply to the current request. + DNS int `json:"dns,omitempty"` + // optional - DNS resolution time. The time required to resolve a host name. + // Use -1 if the timing does not apply to the current request. + Connect int `json:"connect,omitempty"` + // optional - Time required to create TCP connection. Use -1 if the timing + // does not apply to the current request. + Send int `json:"send"` + // Time required to send HTTP request to the server. + Wait int `json:"wait"` + // Waiting for a response from the server. + Receive int `json:"receive"` + // Time required to read entire response from the server (or cache). + Ssl int `json:"ssl,omitempty"` + // optional (new in 1.2) - Time required for SSL/TLS negotiation. If this + // field is defined then the time is also included in the connect field (to + // ensure backward compatibility with HAR 1.1). Use -1 if the timing does not + // apply to the current request. + Comment string `json:"comment,omitempty"` + // optional (new in 1.2) - A comment provided by the user or the application. +} + +// TestResult contains results for an individual HTTP request +type TestResult struct { + URL string `json:"url"` + Status int `json:"status"` // 200, 500, etc. + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + Latency int `json:"latency"` // milliseconds + Method string `json:"method"` + HarFile string `json:"harfile"` +} diff --git a/hrp/internal/json/json.go b/hrp/internal/json/json.go new file mode 100644 index 00000000..859d1e28 --- /dev/null +++ b/hrp/internal/json/json.go @@ -0,0 +1,16 @@ +package json + +import ( + jsoniter "github.com/json-iterator/go" +) + +// replace with third-party json library to improve performance +var json = jsoniter.ConfigCompatibleWithStandardLibrary + +var ( + Marshal = json.Marshal + MarshalIndent = json.MarshalIndent + Unmarshal = json.Unmarshal + NewDecoder = json.NewDecoder + Get = json.Get +) diff --git a/hrp/internal/report/template.html b/hrp/internal/report/template.html new file mode 100644 index 00000000..4bff6c65 --- /dev/null +++ b/hrp/internal/report/template.html @@ -0,0 +1,359 @@ + + + + TestReport + + + + + +

API Test Report

+ +

Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + +
START AT{{.Time.StartAt}}
DURATION{{ .Time.Duration }} seconds
PLATFORMHttpRunnerPlus {{ .Platform.HttprunnerVersion }}{{ .Platform.GoVersion }}{{ .Platform.Platform }}
STATTESTCASES (success/fail)TESTSTEPS (success/fail/error/skip)
total (details) =>{{.Stat.TestCases.Total}} ({{.Stat.TestCases.Success}}/{{.Stat.TestCases.Fail}}){{.Stat.TestSteps.Total}} ({{.Stat.TestSteps.Successes}}/0/{{.Stat.TestSteps.Failures}}/0)
+ +

Details

+{{ range $suite_index, $detail := .Details }} +

{{.Name}}

+ + + + + + + + + + + + + + + {{- range $loop_index, $record := .Records }} + {{- with $record}} + {{- $status := "error"}} + {{- if .Success }} {{ $status = "success" }} {{ end }} + + + + + + + {{- end }} + {{- end }} +
TOTAL: {{.Stat.Total}}SUCCESS: {{.Stat.Successes}}FAILED: 0ERROR: {{.Stat.Failures}}SKIPPED: 0
StatusNameResponse TimeDetail
{{$status}}{{.Name}}{{ .Elapsed }} ms + log + + {{ if .Attachment }} + traceback + + {{- end }} +
+{{- end }} + \ No newline at end of file diff --git a/hrp/internal/scaffold/demo.go b/hrp/internal/scaffold/demo.go new file mode 100644 index 00000000..83338830 --- /dev/null +++ b/hrp/internal/scaffold/demo.go @@ -0,0 +1,260 @@ +package scaffold + +import "github.com/httprunner/httprunner/hrp" + +var demoTestCase = &hrp.TestCase{ + Config: hrp.NewConfig("demo with complex mechanisms"). + SetBaseURL("https://postman-echo.com"). + WithVariables(map[string]interface{}{ // global level variables + "n": "${sum_ints(1, 2, 2)}", + "a": "${sum(10, 2.3)}", + "b": 3.45, + "varFoo1": "${gen_random_string($n)}", + "varFoo2": "${max($a, $b)}", // 12.3; eval with built-in function + }), + TestSteps: []hrp.IStep{ + hrp.NewStep("transaction 1 start").StartTransaction("tran1"), // start transaction + hrp.NewStep("get with params"). + WithVariables(map[string]interface{}{ // step level variables + "n": 3, // inherit config level variables if not set in step level, a/varFoo1 + "b": 34.5, // override config level variable if existed, n/b/varFoo2 + "varFoo2": "${max($a, $b)}", // 34.5; override variable b and eval again + "name": "get with params", + }). + SetupHook("${setup_hook_example($name)}"). + GET("/get"). + TeardownHook("${teardown_hook_example($name)}"). + WithParams(map[string]interface{}{"foo1": "$varFoo1", "foo2": "$varFoo2"}). // request with params + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). // request with headers + Extract(). + WithJmesPath("body.args.foo1", "varFoo1"). // extract variable with jmespath + Validate(). + AssertEqual("status_code", 200, "check response status code"). // validate response status code + AssertStartsWith("headers.\"Content-Type\"", "application/json", ""). // validate response header + AssertLengthEqual("body.args.foo1", 5, "check args foo1"). // validate response body with jmespath + AssertLengthEqual("$varFoo1", 5, "check args foo1"). // assert with extracted variable from current step + AssertEqual("body.args.foo2", "34.5", "check args foo2"), // notice: request params value will be converted to string + hrp.NewStep("transaction 1 end").EndTransaction("tran1"), // end transaction + hrp.NewStep("post json data"). + POST("/post"). + WithBody(map[string]interface{}{ + "foo1": "$varFoo1", // reference former extracted variable + "foo2": "${max($a, $b)}", // 12.3; step level variables are independent, variable b is 3.45 here + }). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.json.foo1", 5, "check args foo1"). + AssertEqual("body.json.foo2", 12.3, "check args foo2"), + hrp.NewStep("post form data"). + POST("/post"). + WithHeaders(map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}). + WithBody(map[string]interface{}{ + "foo1": "$varFoo1", // reference former extracted variable + "foo2": "${max($a, $b)}", // 12.3; step level variables are independent, variable b is 3.45 here + "time": "${get_timestamp()}", + }). + Extract(). + WithJmesPath("body.form.time", "varTime"). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.form.foo1", 5, "check args foo1"). + AssertEqual("body.form.foo2", "12.3", "check args foo2"), // form data will be converted to string + hrp.NewStep("get with timestamp"). + GET("/get").WithParams(map[string]interface{}{"time": "$varTime"}). + Validate(). + AssertLengthEqual("body.args.time", 13, "check extracted var timestamp"), + }, +} + +var demoTestCaseWithoutPlugin = &hrp.TestCase{ + Config: hrp.NewConfig("demo without custom function plugin"). + SetBaseURL("https://postman-echo.com"). + WithVariables(map[string]interface{}{ // global level variables + "n": 5, + "a": 12.3, + "b": 3.45, + "varFoo1": "${gen_random_string($n)}", + "varFoo2": "${max($a, $b)}", // 12.3; eval with built-in function + }), + TestSteps: []hrp.IStep{ + hrp.NewStep("transaction 1 start").StartTransaction("tran1"), // start transaction + hrp.NewStep("get with params"). + WithVariables(map[string]interface{}{ // step level variables + "n": 3, // inherit config level variables if not set in step level, a/varFoo1 + "b": 34.5, // override config level variable if existed, n/b/varFoo2 + "varFoo2": "${max($a, $b)}", // 34.5; override variable b and eval again + "name": "get with params", + }). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "$varFoo1", "foo2": "$varFoo2"}). // request with params + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). // request with headers + Extract(). + WithJmesPath("body.args.foo1", "varFoo1"). // extract variable with jmespath + Validate(). + AssertEqual("status_code", 200, "check response status code"). // validate response status code + AssertStartsWith("headers.\"Content-Type\"", "application/json", ""). // validate response header + AssertLengthEqual("body.args.foo1", 5, "check args foo1"). // validate response body with jmespath + AssertLengthEqual("$varFoo1", 5, "check args foo1"). // assert with extracted variable from current step + AssertEqual("body.args.foo2", "34.5", "check args foo2"), // notice: request params value will be converted to string + hrp.NewStep("transaction 1 end").EndTransaction("tran1"), // end transaction + hrp.NewStep("post json data"). + POST("/post"). + WithBody(map[string]interface{}{ + "foo1": "$varFoo1", // reference former extracted variable + "foo2": "${max($a, $b)}", // 12.3; step level variables are independent, variable b is 3.45 here + }). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.json.foo1", 5, "check args foo1"). + AssertEqual("body.json.foo2", 12.3, "check args foo2"), + hrp.NewStep("post form data"). + POST("/post"). + WithHeaders(map[string]string{"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}). + WithBody(map[string]interface{}{ + "foo1": "$varFoo1", // reference former extracted variable + "foo2": "${max($a, $b)}", // 12.3; step level variables are independent, variable b is 3.45 here + "time": "${get_timestamp()}", + }). + Extract(). + WithJmesPath("body.form.time", "varTime"). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertLengthEqual("body.form.foo1", 5, "check args foo1"). + AssertEqual("body.form.foo2", "12.3", "check args foo2"), // form data will be converted to string + hrp.NewStep("get with timestamp"). + GET("/get").WithParams(map[string]interface{}{"time": "$varTime"}). + Validate(). + AssertLengthEqual("body.args.time", 13, "check extracted var timestamp"), + }, +} + +// debugtalk.go +var demoGoPlugin = `package main + +import ( + "fmt" + + "github.com/httprunner/funplugin/fungo" +) + +func SumTwoInt(a, b int) int { + return a + b +} + +func SumInts(args ...int) int { + var sum int + for _, arg := range args { + sum += arg + } + return sum +} + +func Sum(args ...interface{}) (interface{}, error) { + var sum float64 + for _, arg := range args { + switch v := arg.(type) { + case int: + sum += float64(v) + case float64: + sum += v + default: + return nil, fmt.Errorf("unexpected type: %T", arg) + } + } + return sum, nil +} + +func SetupHookExample(args string) string { + return fmt.Sprintf("step name: %v, setup...", args) +} + +func TeardownHookExample(args string) string { + return fmt.Sprintf("step name: %v, teardown...", args) +} + +func main() { + fungo.Register("sum_ints", SumInts) + fungo.Register("sum_two_int", SumTwoInt) + fungo.Register("sum", Sum) + fungo.Register("setup_hook_example", SetupHookExample) + fungo.Register("teardown_hook_example", TeardownHookExample) + fungo.Serve() +} +` + +// debugtalk.py +var demoPyPlugin = `import logging +from typing import List + +import funppy + + +def sum(*args): + result = 0 + for arg in args: + result += arg + return result + +def sum_ints(*args: List[int]) -> int: + result = 0 + for arg in args: + result += arg + return result + +def sum_two_int(a: int, b: int) -> int: + return a + b + +def sum_two_string(a: str, b: str) -> str: + return a + b + +def sum_strings(*args: List[str]) -> str: + result = "" + for arg in args: + result += arg + return result + +def concatenate(*args: List[str]) -> str: + result = "" + for arg in args: + result += str(arg) + return result + +def setup_hook_example(name): + logging.warning("setup_hook_example") + return f"setup_hook_example: {name}" + +def teardown_hook_example(name): + logging.warning("teardown_hook_example") + return f"teardown_hook_example: {name}" + + +if __name__ == '__main__': + funppy.register("sum", sum) + funppy.register("sum_ints", sum_ints) + funppy.register("concatenate", concatenate) + funppy.register("sum_two_int", sum_two_int) + funppy.register("sum_two_string", sum_two_string) + funppy.register("sum_strings", sum_strings) + funppy.register("setup_hook_example", setup_hook_example) + funppy.register("teardown_hook_example", teardown_hook_example) + funppy.serve() +` + +// .gitignore +var demoIgnoreContent = `.env +reports/ +*.so +.vscode/ +.idea/ +.DS_Store +output/ + +# plugin +debugtalk.bin +debugtalk.so +` + +// .env +var demoEnvContent = `USERNAME=debugtalk +PASSWORD=123456 +` diff --git a/hrp/internal/scaffold/demo_test.go b/hrp/internal/scaffold/demo_test.go new file mode 100644 index 00000000..4a88ead3 --- /dev/null +++ b/hrp/internal/scaffold/demo_test.go @@ -0,0 +1,75 @@ +package scaffold + +import ( + "os" + "os/exec" + "testing" + + "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/hrp/internal/builtin" +) + +var ( + demoTestCaseJSONPath hrp.TestCasePath = "../../../examples/hrp/demo.json" + demoTestCaseYAMLPath hrp.TestCasePath = "../../../examples/hrp/demo.yaml" +) + +func buildHashicorpPlugin() { + log.Info().Msg("[init] build hashicorp go plugin") + cmd := exec.Command("go", "build", + "-o", "../../../examples/hrp/debugtalk.bin", + "../../../examples/hrp/plugin/hashicorp.go", "../../../examples/hrp/plugin/debugtalk.go") + if err := cmd.Run(); err != nil { + panic(err) + } +} + +func removeHashicorpPlugin() { + log.Info().Msg("[teardown] remove hashicorp plugin") + os.Remove("../../../examples/hrp/debugtalk.bin") +} + +func TestGenDemoTestCase(t *testing.T) { + tCase, _ := demoTestCase.ToTCase() + err := builtin.Dump2JSON(tCase, demoTestCaseJSONPath.ToString()) + if err != nil { + t.Fail() + } + err = builtin.Dump2YAML(tCase, demoTestCaseYAMLPath.ToString()) + if err != nil { + t.Fail() + } +} + +func TestExampleDemo(t *testing.T) { + buildHashicorpPlugin() + defer removeHashicorpPlugin() + + demoTestCase.Config.Path = "../../../examples/hrp/debugtalk.bin" + err := hrp.NewRunner(nil).Run(demoTestCase) // hrp.Run(demoTestCase) + if err != nil { + t.Fail() + } +} + +func TestJsonDemo(t *testing.T) { + buildHashicorpPlugin() + defer removeHashicorpPlugin() + + err := hrp.NewRunner(nil).Run(&demoTestCaseJSONPath) // hrp.Run(testCase) + if err != nil { + t.Fail() + } +} + +func TestYamlDemo(t *testing.T) { + buildHashicorpPlugin() + defer removeHashicorpPlugin() + + err := hrp.NewRunner(nil).Run(&demoTestCaseYAMLPath) // hrp.Run(testCase) + if err != nil { + t.Fail() + } +} diff --git a/hrp/internal/scaffold/main.go b/hrp/internal/scaffold/main.go new file mode 100644 index 00000000..3e8bab30 --- /dev/null +++ b/hrp/internal/scaffold/main.go @@ -0,0 +1,150 @@ +package scaffold + +import ( + "fmt" + "os" + "os/exec" + "path" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + + "github.com/httprunner/funplugin/shared" + "github.com/httprunner/httprunner/hrp" + "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/hrp/internal/ga" +) + +type PluginType string + +const ( + Ignore PluginType = "ignore" + Py PluginType = "py" + Go PluginType = "go" +) + +func CreateScaffold(projectName string, pluginType PluginType) error { + // report event + ga.SendEvent(ga.EventTracking{ + Category: "Scaffold", + Action: "hrp startproject", + }) + + // check if projectName exists + if _, err := os.Stat(projectName); err == nil { + log.Warn().Str("projectName", projectName). + Msg("project name already exists, please specify a new one.") + return fmt.Errorf("project name already exists") + } + + log.Info(). + Str("projectName", projectName). + Str("pluginType", string(pluginType)). + Msg("create new scaffold project") + + // create project folders + if err := builtin.CreateFolder(projectName); err != nil { + return err + } + if err := builtin.CreateFolder(path.Join(projectName, "har")); err != nil { + return err + } + if err := builtin.CreateFolder(path.Join(projectName, "testcases")); err != nil { + return err + } + if err := builtin.CreateFolder(path.Join(projectName, "reports")); err != nil { + return err + } + + // create demo testcases + var tCase *hrp.TCase + if pluginType == Ignore { + tCase, _ = demoTestCaseWithoutPlugin.ToTCase() + } else { + tCase, _ = demoTestCase.ToTCase() + } + err := builtin.Dump2JSON(tCase, path.Join(projectName, "testcases", "demo.json")) + if err != nil { + log.Error().Err(err).Msg("create demo.json testcase failed") + return err + } + err = builtin.Dump2YAML(tCase, path.Join(projectName, "testcases", "demo.yaml")) + if err != nil { + log.Error().Err(err).Msg("create demo.yml testcase failed") + return err + } + + // create .gitignore + if err := builtin.CreateFile(path.Join(projectName, ".gitignore"), demoIgnoreContent); err != nil { + return err + } + // create .env + if err := builtin.CreateFile(path.Join(projectName, ".env"), demoEnvContent); err != nil { + return err + } + + // create debugtalk function plugin + switch pluginType { + case Ignore: + log.Info().Msg("skip creating function plugin") + return nil + case Py: + return createPythonPlugin(projectName) + case Go: + return createGoPlugin(projectName) + } + + return nil +} + +func createGoPlugin(projectName string) error { + log.Info().Msg("start to create hashicorp go plugin") + // check go sdk + if err := builtin.ExecCommand(exec.Command("go", "version"), projectName); err != nil { + return errors.Wrap(err, "go sdk not installed") + } + + // create debugtalk.go + pluginDir := path.Join(projectName, "plugin") + if err := builtin.CreateFolder(pluginDir); err != nil { + return err + } + pluginFile := path.Join(pluginDir, "debugtalk.go") + if err := builtin.CreateFile(pluginFile, demoGoPlugin); err != nil { + return err + } + + // create go mod + if err := builtin.ExecCommand(exec.Command("go", "mod", "init", "plugin"), pluginDir); err != nil { + return err + } + + // download plugin dependency + if err := builtin.ExecCommand(exec.Command("go", "get", "github.com/httprunner/funplugin"), pluginDir); err != nil { + return err + } + + // build plugin debugtalk.bin + if err := builtin.ExecCommand(exec.Command("go", "build", "-o", path.Join("..", "debugtalk.bin"), "debugtalk.go"), pluginDir); err != nil { + return err + } + + return nil +} + +func createPythonPlugin(projectName string) error { + log.Info().Msg("start to create hashicorp python plugin") + + // create debugtalk.py + pluginFile := path.Join(projectName, "debugtalk.py") + if err := builtin.CreateFile(pluginFile, demoPyPlugin); err != nil { + return err + } + + // create python venv + if _, err := shared.PreparePython3Venv(pluginFile); err != nil { + return err + } + + return nil +} diff --git a/hrp/internal/version/init.go b/hrp/internal/version/init.go new file mode 100644 index 00000000..720fe5c6 --- /dev/null +++ b/hrp/internal/version/init.go @@ -0,0 +1,3 @@ +package version + +const VERSION = "v4.0.0-alpha" diff --git a/hrp/models.go b/hrp/models.go new file mode 100644 index 00000000..49398429 --- /dev/null +++ b/hrp/models.go @@ -0,0 +1,454 @@ +package hrp + +import ( + "fmt" + "math/rand" + "reflect" + "runtime" + "sync" + "time" + + "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/hrp/internal/version" +) + +const ( + httpGET string = "GET" + httpHEAD string = "HEAD" + httpPOST string = "POST" + httpPUT string = "PUT" + httpDELETE string = "DELETE" + httpOPTIONS string = "OPTIONS" + httpPATCH string = "PATCH" +) + +// TConfig represents config data structure for testcase. +// Each testcase should contain one config part. +type TConfig struct { + Name string `json:"name" yaml:"name"` // required + Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"` + BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty"` + Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` + Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` + Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty"` + ParametersSetting *TParamsConfig `json:"parameters_setting,omitempty" yaml:"parameters_setting,omitempty"` + ThinkTime *ThinkTimeConfig `json:"think_time,omitempty" yaml:"think_time,omitempty"` + Export []string `json:"export,omitempty" yaml:"export,omitempty"` + Weight int `json:"weight,omitempty" yaml:"weight,omitempty"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` // testcase file path +} + +type TParamsConfig struct { + Strategy interface{} `json:"strategy,omitempty" yaml:"strategy,omitempty"` // map[string]string、string + Iteration int `json:"iteration,omitempty" yaml:"iteration,omitempty"` + Iterators []*Iterator `json:"parameterIterator,omitempty" yaml:"parameterIterator,omitempty"` // 保存参数的迭代器 +} + +const ( + strategyRandom string = "random" + strategySequential string = "Sequential" +) + +type ThinkTimeConfig struct { + Strategy string `json:"strategy,omitempty" yaml:"strategy,omitempty"` // default、random、limit、multiply、ignore + Setting interface{} `json:"setting,omitempty" yaml:"setting,omitempty"` // random(map): {"min_percentage": 0.5, "max_percentage": 1.5}; 10、multiply(float64): 1.5 + Limit float64 `json:"limit,omitempty" yaml:"limit,omitempty"` // limit think time no more than specific time, ignore if value <= 0 +} + +const ( + thinkTimeDefault string = "default" // as recorded + thinkTimeRandomPercentage string = "random_percentage" // use random percentage of recorded think time + thinkTimeMultiply string = "multiply" // multiply recorded think time + thinkTimeIgnore string = "ignore" // ignore recorded think time +) + +const ( + thinkTimeDefaultMultiply = 1 +) + +var ( + thinkTimeDefaultRandom = map[string]float64{"min_percentage": 0.5, "max_percentage": 1.5} +) + +func (ttc *ThinkTimeConfig) checkThinkTime() { + if ttc == nil { + return + } + // unset strategy, set default strategy + if ttc.Strategy == "" { + ttc.Strategy = thinkTimeDefault + } + // check think time + if ttc.Strategy == thinkTimeRandomPercentage { + if ttc.Setting == nil || reflect.TypeOf(ttc.Setting).Kind() != reflect.Map { + ttc.Setting = thinkTimeDefaultRandom + return + } + value, ok := ttc.Setting.(map[string]interface{}) + if !ok { + ttc.Setting = thinkTimeDefaultRandom + return + } + if _, ok := value["min_percentage"]; !ok { + ttc.Setting = thinkTimeDefaultRandom + return + } + if _, ok := value["max_percentage"]; !ok { + ttc.Setting = thinkTimeDefaultRandom + return + } + left, err := builtin.Interface2Float64(value["min_percentage"]) + if err != nil { + ttc.Setting = thinkTimeDefaultRandom + return + } + right, err := builtin.Interface2Float64(value["max_percentage"]) + if err != nil { + ttc.Setting = thinkTimeDefaultRandom + return + } + ttc.Setting = map[string]float64{"min_percentage": left, "max_percentage": right} + } else if ttc.Strategy == thinkTimeMultiply { + if ttc.Setting == nil { + ttc.Setting = float64(0) // default + return + } + value, err := builtin.Interface2Float64(ttc.Setting) + if err != nil { + ttc.Setting = float64(0) // default + return + } + ttc.Setting = value + } else if ttc.Strategy != thinkTimeIgnore { + // unrecognized strategy, set default strategy + ttc.Strategy = thinkTimeDefault + } +} + +type paramsType []map[string]interface{} + +type Iterator struct { + sync.Mutex + data paramsType + strategy string // random, sequential + iteration int + index int +} + +func (params paramsType) Iterator() *Iterator { + return &Iterator{ + data: params, + iteration: len(params), + index: 0, + } +} + +func (iter *Iterator) HasNext() bool { + if iter.iteration == -1 { + return true + } + return iter.index < iter.iteration +} + +func (iter *Iterator) Next() (value map[string]interface{}) { + iter.Lock() + defer iter.Unlock() + if len(iter.data) == 0 { + iter.index++ + return map[string]interface{}{} + } + if iter.strategy == strategyRandom { + randSource := rand.New(rand.NewSource(time.Now().Unix())) + randIndex := randSource.Intn(len(iter.data)) + value = iter.data[randIndex] + } else { + value = iter.data[iter.index%len(iter.data)] + } + iter.index++ + return value +} + +// Request represents HTTP request data structure. +// This is used for teststep. +type Request struct { + Method string `json:"method" yaml:"method"` // required + URL string `json:"url" yaml:"url"` // required + Params map[string]interface{} `json:"params,omitempty" yaml:"params,omitempty"` + Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` + Cookies map[string]string `json:"cookies,omitempty" yaml:"cookies,omitempty"` + Body interface{} `json:"body,omitempty" yaml:"body,omitempty"` + Json interface{} `json:"json,omitempty" yaml:"json,omitempty"` + Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` + Timeout float32 `json:"timeout,omitempty" yaml:"timeout,omitempty"` + AllowRedirects bool `json:"allow_redirects,omitempty" yaml:"allow_redirects,omitempty"` + Verify bool `json:"verify,omitempty" yaml:"verify,omitempty"` +} + +type API struct { + Name string `json:"name" yaml:"name"` // required + Request *Request `json:"request,omitempty" yaml:"request,omitempty"` + Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` + SetupHooks []string `json:"setup_hooks,omitempty" yaml:"setup_hooks,omitempty"` + TeardownHooks []string `json:"teardown_hooks,omitempty" yaml:"teardown_hooks,omitempty"` + Extract map[string]string `json:"extract,omitempty" yaml:"extract,omitempty"` + Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"` + Export []string `json:"export,omitempty" yaml:"export,omitempty"` +} + +func (api *API) ToAPI() (*API, error) { + return api, nil +} + +// Validator represents validator for one HTTP response. +type Validator struct { + Check string `json:"check" yaml:"check"` // get value with jmespath + Assert string `json:"assert" yaml:"assert"` + Expect interface{} `json:"expect" yaml:"expect"` + Message string `json:"msg,omitempty" yaml:"msg,omitempty"` // optional +} + +// IAPI represents interface for api, +// includes API and APIPath. +type IAPI interface { + ToAPI() (*API, error) +} + +// TStep represents teststep data structure. +// Each step maybe two different type: make one HTTP request or reference another testcase. +type TStep struct { + Name string `json:"name" yaml:"name"` // required + Request *Request `json:"request,omitempty" yaml:"request,omitempty"` + APIPath string `json:"api,omitempty" yaml:"api,omitempty"` + TestCasePath string `json:"testcase,omitempty" yaml:"testcase,omitempty"` + APIContent IAPI `json:"api_content,omitempty" yaml:"api_content,omitempty"` + TestCaseContent ITestCase `json:"testcase_content,omitempty" yaml:"testcase_content,omitempty"` + Transaction *Transaction `json:"transaction,omitempty" yaml:"transaction,omitempty"` + Rendezvous *Rendezvous `json:"rendezvous,omitempty" yaml:"rendezvous,omitempty"` + ThinkTime *ThinkTime `json:"think_time,omitempty" yaml:"think_time,omitempty"` + Variables map[string]interface{} `json:"variables,omitempty" yaml:"variables,omitempty"` + SetupHooks []string `json:"setup_hooks,omitempty" yaml:"setup_hooks,omitempty"` + TeardownHooks []string `json:"teardown_hooks,omitempty" yaml:"teardown_hooks,omitempty"` + Extract map[string]string `json:"extract,omitempty" yaml:"extract,omitempty"` + Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"` + Export []string `json:"export,omitempty" yaml:"export,omitempty"` +} + +type stepType string + +const ( + stepTypeRequest stepType = "request" + stepTypeTestCase stepType = "testcase" + stepTypeTransaction stepType = "transaction" + stepTypeRendezvous stepType = "rendezvous" + stepTypeThinkTime stepType = "thinktime" +) + +type ThinkTime struct { + Time float64 `json:"time" yaml:"time"` +} + +type transactionType string + +const ( + transactionStart transactionType = "start" + transactionEnd transactionType = "end" +) + +type Transaction struct { + Name string `json:"name" yaml:"name"` + Type transactionType `json:"type" yaml:"type"` +} + +const ( + defaultRendezvousTimeout int64 = 5000 + defaultRendezvousPercent float32 = 1.0 +) + +type Rendezvous struct { + Name string `json:"name" yaml:"name"` // required + Percent float32 `json:"percent,omitempty" yaml:"percent,omitempty"` // default to 1(100%) + Number int64 `json:"number,omitempty" yaml:"number,omitempty"` + Timeout int64 `json:"timeout,omitempty" yaml:"timeout,omitempty"` // milliseconds + cnt int64 + releasedFlag uint32 + spawnDoneFlag uint32 + wg sync.WaitGroup + timerResetChan chan struct{} + activateChan chan struct{} + releaseChan chan struct{} + once *sync.Once + lock sync.Mutex +} + +// TCase represents testcase data structure. +// Each testcase includes one public config and several sequential teststeps. +type TCase struct { + Config *TConfig `json:"config" yaml:"config"` + TestSteps []*TStep `json:"teststeps" yaml:"teststeps"` +} + +// IStep represents interface for all types for teststeps, includes: +// StepRequest, StepRequestWithOptionalArgs, StepRequestValidation, StepRequestExtraction, +// StepTestCaseWithOptionalArgs, +// StepTransaction, StepRendezvous. +type IStep interface { + Name() string + Type() string + ToStruct() *TStep +} + +// ITestCase represents interface for testcases, +// includes TestCase and TestCasePath. +type ITestCase interface { + ToTestCase() (*TestCase, error) + ToTCase() (*TCase, error) +} + +// TestCase is a container for one testcase, which is used for testcase runner. +// TestCase implements ITestCase interface. +type TestCase struct { + Config *TConfig + TestSteps []IStep +} + +func (tc *TestCase) ToTestCase() (*TestCase, error) { + return tc, nil +} + +func (tc *TestCase) ToTCase() (*TCase, error) { + tCase := TCase{ + Config: tc.Config, + } + for _, step := range tc.TestSteps { + tCase.TestSteps = append(tCase.TestSteps, step.ToStruct()) + } + return &tCase, nil +} + +type testCaseStat struct { + Total int `json:"total" yaml:"total"` + Success int `json:"success" yaml:"success"` + Fail int `json:"fail" yaml:"fail"` +} + +type testStepStat struct { + Total int `json:"total" yaml:"total"` + Successes int `json:"successes" yaml:"successes"` + Failures int `json:"failures" yaml:"failures"` +} + +type stat struct { + TestCases testCaseStat `json:"testcases" yaml:"test_cases"` + TestSteps testStepStat `json:"teststeps" yaml:"test_steps"` +} + +type testCaseTime struct { + StartAt time.Time `json:"start_at,omitempty" yaml:"start_at,omitempty"` + Duration float64 `json:"duration,omitempty" yaml:"duration,omitempty"` +} + +type platform struct { + HttprunnerVersion string `json:"httprunner_version" yaml:"httprunner_version"` + GoVersion string `json:"go_version" yaml:"go_version"` + Platform string `json:"platform" yaml:"platform"` +} + +// Summary stores tests summary for current task execution, maybe include one or multiple testcases +type Summary struct { + Success bool `json:"success" yaml:"success"` + Stat *stat `json:"stat" yaml:"stat"` + Time *testCaseTime `json:"time" yaml:"time"` + Platform *platform `json:"platform" yaml:"platform"` + Details []*testCaseSummary `json:"details" yaml:"details"` +} + +func newOutSummary() *Summary { + platForm := &platform{ + HttprunnerVersion: version.VERSION, + GoVersion: runtime.Version(), + Platform: fmt.Sprintf("%v-%v", runtime.GOOS, runtime.GOARCH), + } + return &Summary{ + Success: true, + Stat: &stat{}, + Time: &testCaseTime{ + StartAt: time.Now(), + }, + Platform: platForm, + } +} + +func (s *Summary) appendCaseSummary(caseSummary *testCaseSummary) { + s.Success = s.Success && caseSummary.Success + s.Stat.TestCases.Total += 1 + s.Stat.TestSteps.Total += len(caseSummary.Records) + if caseSummary.Success { + s.Stat.TestCases.Success += 1 + } else { + s.Stat.TestCases.Fail += 1 + } + s.Stat.TestSteps.Successes += caseSummary.Stat.Successes + s.Stat.TestSteps.Failures += caseSummary.Stat.Failures + s.Details = append(s.Details, caseSummary) + s.Success = s.Success && caseSummary.Success +} + +type stepData struct { + Name string `json:"name" yaml:"name"` // step name + StepType stepType `json:"step_type" yaml:"step_type"` // step type, testcase/request/transaction/rendezvous + Success bool `json:"success" yaml:"success"` // step execution result + Elapsed int64 `json:"elapsed_ms" yaml:"elapsed_ms"` // step execution time in millisecond(ms) + Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` // session data or slice of step data + ContentSize int64 `json:"content_size" yaml:"content_size"` // response body length + ExportVars map[string]interface{} `json:"export_vars,omitempty" yaml:"export_vars,omitempty"` // extract variables + Attachment string `json:"attachment,omitempty" yaml:"attachment,omitempty"` // step error information +} + +type testCaseInOut struct { + ConfigVars map[string]interface{} `json:"config_vars" yaml:"config_vars"` + ExportVars map[string]interface{} `json:"export_vars" yaml:"export_vars"` +} + +// testCaseSummary stores tests summary for one testcase +type testCaseSummary struct { + Name string `json:"name" yaml:"name"` + Success bool `json:"success" yaml:"success"` + CaseId string `json:"case_id,omitempty" yaml:"case_id,omitempty"` // TODO + Stat *testStepStat `json:"stat" yaml:"stat"` + Time *testCaseTime `json:"time" yaml:"time"` + InOut *testCaseInOut `json:"in_out" yaml:"in_out"` + Log string `json:"log,omitempty" yaml:"log,omitempty"` // TODO + Records []*stepData `json:"records" yaml:"records"` +} + +type validationResult struct { + Validator + CheckValue interface{} `json:"check_value" yaml:"check_value"` + CheckResult string `json:"check_result" yaml:"check_result"` +} + +type reqResps struct { + Request interface{} `json:"request" yaml:"request"` + Response interface{} `json:"response" yaml:"response"` +} + +type address struct { + ClientIP string `json:"client_ip,omitempty" yaml:"client_ip,omitempty"` + ClientPort string `json:"client_port,omitempty" yaml:"client_port,omitempty"` + ServerIP string `json:"server_ip,omitempty" yaml:"server_ip,omitempty"` + ServerPort string `json:"server_port,omitempty" yaml:"server_port,omitempty"` +} + +type SessionData struct { + Success bool `json:"success" yaml:"success"` + ReqResps *reqResps `json:"req_resps" yaml:"req_resps"` + Address *address `json:"address,omitempty" yaml:"address,omitempty"` // TODO + Validators []*validationResult `json:"validators,omitempty" yaml:"validators,omitempty"` +} + +func newSessionData() *SessionData { + return &SessionData{ + Success: false, + ReqResps: &reqResps{}, + } +} diff --git a/hrp/parser.go b/hrp/parser.go new file mode 100644 index 00000000..10352938 --- /dev/null +++ b/hrp/parser.go @@ -0,0 +1,730 @@ +package hrp + +import ( + builtinJSON "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strings" + + "github.com/maja42/goval" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + + "github.com/httprunner/funplugin" + "github.com/httprunner/funplugin/shared" + "github.com/httprunner/httprunner/hrp/internal/builtin" +) + +func newParser() *parser { + return &parser{} +} + +type parser struct { + plugin funplugin.IPlugin // plugin is used to call functions +} + +func buildURL(baseURL, stepURL string) string { + uConfig, err := url.Parse(baseURL) + if err != nil { + log.Error().Str("baseURL", baseURL).Err(err).Msg("[buildURL] parse baseURL failed") + return "" + } + + uStep, err := uConfig.Parse(stepURL) + if err != nil { + log.Error().Str("stepURL", stepURL).Err(err).Msg("[buildURL] parse stepURL failed") + return "" + } + + // base url missed + return uStep.String() +} + +func (p *parser) parseHeaders(rawHeaders map[string]string, variablesMapping map[string]interface{}) (map[string]string, error) { + parsedHeaders := make(map[string]string) + headers, err := p.parseData(rawHeaders, variablesMapping) + if err != nil { + return rawHeaders, err + } + for k, v := range headers.(map[string]interface{}) { + parsedHeaders[k] = convertString(v) + } + return parsedHeaders, nil +} + +func convertString(raw interface{}) string { + if value, ok := raw.(string); ok { + return value + } else { + // raw is not string, e.g. int, float, etc. + // convert to string + return fmt.Sprintf("%v", raw) + } +} + +func (p *parser) parseData(raw interface{}, variablesMapping map[string]interface{}) (interface{}, error) { + rawValue := reflect.ValueOf(raw) + switch rawValue.Kind() { + case reflect.String: + // json.Number + if rawValue, ok := raw.(builtinJSON.Number); ok { + return parseJSONNumber(rawValue) + } + // other string + value := rawValue.String() + value = strings.TrimSpace(value) + return p.parseString(value, variablesMapping) + case reflect.Slice: + parsedSlice := make([]interface{}, rawValue.Len()) + for i := 0; i < rawValue.Len(); i++ { + parsedValue, err := p.parseData(rawValue.Index(i).Interface(), variablesMapping) + if err != nil { + return raw, err + } + parsedSlice[i] = parsedValue + } + return parsedSlice, nil + case reflect.Map: // convert any map to map[string]interface{} + parsedMap := make(map[string]interface{}) + for _, k := range rawValue.MapKeys() { + parsedKey, err := p.parseString(k.String(), variablesMapping) + if err != nil { + return raw, err + } + v := rawValue.MapIndex(k) + parsedValue, err := p.parseData(v.Interface(), variablesMapping) + if err != nil { + return raw, err + } + + key := convertString(parsedKey) + parsedMap[key] = parsedValue + } + return parsedMap, nil + default: + // other types, e.g. nil, int, float, bool + return raw, nil + } +} + +func parseJSONNumber(raw builtinJSON.Number) (interface{}, error) { + if strings.Contains(raw.String(), ".") { + // float64 + return raw.Float64() + } else { + // int64 + return raw.Int64() + } +} + +const ( + regexVariable = `[a-zA-Z_]\w*` // variable name should start with a letter or underscore + regexFunctionName = `[a-zA-Z_]\w*` // function name should start with a letter or underscore + regexNumber = `^-?\d+(\.\d+)?$` // match number, e.g. 123, -123, 1.23, -1.23 +) + +var ( + regexCompileVariable = regexp.MustCompile(fmt.Sprintf(`\$\{(%s)\}|\$(%s)`, regexVariable, regexVariable)) // parse ${var} or $var + regexCompileFunction = regexp.MustCompile(fmt.Sprintf(`\$\{(%s)\(([\$\w\.\-/\s=,]*)\)\}`, regexFunctionName)) // parse ${func1($a, $b)} + regexCompileNumber = regexp.MustCompile(regexNumber) // parse number +) + +// parseString parse string with variables +func (p *parser) parseString(raw string, variablesMapping map[string]interface{}) (interface{}, error) { + matchStartPosition := 0 + parsedString := "" + remainedString := raw + + for matchStartPosition < len(raw) { + // locate $ char position + startPosition := strings.Index(remainedString, "$") + if startPosition == -1 { // no $ found + // append remained string + parsedString += remainedString + break + } + + // found $, check if variable or function + matchStartPosition += startPosition + parsedString += remainedString[0:startPosition] + remainedString = remainedString[startPosition:] + + // Notice: notation priority + // $$ > ${func($a, $b)} > $var + + // search $$, use $$ to escape $ notation + if strings.HasPrefix(remainedString, "$$") { // found $$ + matchStartPosition += 2 + parsedString += "$" + remainedString = remainedString[2:] + continue + } + + // search function like ${func($a, $b)} + funcMatched := regexCompileFunction.FindStringSubmatch(remainedString) + if len(funcMatched) == 3 { + funcName := funcMatched[1] + argsStr := funcMatched[2] + arguments, err := parseFunctionArguments(argsStr) + if err != nil { + return raw, err + } + parsedArgs, err := p.parseData(arguments, variablesMapping) + if err != nil { + return raw, err + } + + result, err := p.callFunc(funcName, parsedArgs.([]interface{})...) + if err != nil { + log.Error().Str("funcName", funcName).Interface("arguments", arguments). + Err(err).Msg("call function failed") + return raw, err + } + log.Info().Str("funcName", funcName).Interface("arguments", arguments). + Interface("output", result).Msg("call function success") + + if funcMatched[0] == raw { + // raw_string is a function, e.g. "${add_one(3)}", return its eval value directly + return result, nil + } + + // raw_string contains one or many functions, e.g. "abc${add_one(3)}def" + matchStartPosition += len(funcMatched[0]) + parsedString += fmt.Sprintf("%v", result) + remainedString = raw[matchStartPosition:] + log.Debug(). + Str("parsedString", parsedString). + Int("matchStartPosition", matchStartPosition). + Msg("[parseString] parse function") + continue + } + + // search variable like ${var} or $var + varMatched := regexCompileVariable.FindStringSubmatch(remainedString) + if len(varMatched) == 3 { + var varName string + if varMatched[1] != "" { + varName = varMatched[1] // match ${var} + } else { + varName = varMatched[2] // match $var + } + varValue, ok := variablesMapping[varName] + if !ok { + return raw, fmt.Errorf("variable %s not found", varName) + } + + if fmt.Sprintf("${%s}", varName) == raw || fmt.Sprintf("$%s", varName) == raw { + // raw string is a variable, $var or ${var}, return its value directly + return varValue, nil + } + + matchStartPosition += len(varMatched[0]) + parsedString += fmt.Sprintf("%v", varValue) + remainedString = raw[matchStartPosition:] + log.Debug(). + Str("parsedString", parsedString). + Int("matchStartPosition", matchStartPosition). + Msg("[parseString] parse variable") + continue + } + + parsedString += remainedString + break + } + + return parsedString, nil +} + +// callFunc calls function with arguments +// only support return at most one result value +func (p *parser) callFunc(funcName string, arguments ...interface{}) (interface{}, error) { + // call with plugin function + if p.plugin != nil && p.plugin.Has(funcName) { + return p.plugin.Call(funcName, arguments...) + } + + // get builtin function + function, ok := builtin.Functions[funcName] + if !ok { + return nil, fmt.Errorf("function %s is not found", funcName) + } + fn := reflect.ValueOf(function) + + // call with builtin function + return shared.CallFunc(fn, arguments...) +} + +// merge two variables mapping, the first variables have higher priority +func mergeVariables(variables, overriddenVariables map[string]interface{}) map[string]interface{} { + if overriddenVariables == nil { + return variables + } + if variables == nil { + return overriddenVariables + } + + mergedVariables := make(map[string]interface{}) + for k, v := range overriddenVariables { + mergedVariables[k] = v + } + for k, v := range variables { + if fmt.Sprintf("${%s}", k) == v || fmt.Sprintf("$%s", k) == v { + // e.g. {"base_url": "$base_url"} + // or {"base_url": "${base_url}"} + continue + } + + mergedVariables[k] = v + } + return mergedVariables +} + +// merge two map, the first map have higher priority +func mergeMap(m, overriddenMap map[string]string) map[string]string { + if overriddenMap == nil { + return m + } + if m == nil { + return overriddenMap + } + + mergedMap := make(map[string]string) + for k, v := range overriddenMap { + mergedMap[k] = v + } + for k, v := range m { + mergedMap[k] = v + } + return mergedMap +} + +// merge two validators slice, the first validators have higher priority +func mergeValidators(validators, overriddenValidators []interface{}) []interface{} { + if validators == nil { + return overriddenValidators + } + if overriddenValidators == nil { + return validators + } + var mergedValidators []interface{} + validators = append(validators, overriddenValidators...) + for _, validator := range validators { + flag := true + for _, mergedValidator := range mergedValidators { + if validator.(Validator).Check == mergedValidator.(Validator).Check { + flag = false + break + } + } + if flag { + mergedValidators = append(mergedValidators, validator) + } + } + return mergedValidators +} + +// merge two slices, the first slice have higher priority +func mergeSlices(slice, overriddenSlice []string) []string { + if slice == nil { + return overriddenSlice + } + if overriddenSlice == nil { + return slice + } + + for _, value := range overriddenSlice { + if !builtin.Contains(slice, value) { + slice = append(slice, value) + } + } + return slice +} + +// extend teststep with api, teststep will merge and override referenced api +func extendWithAPI(testStep *TStep, overriddenStep *API) { + // override api name + if testStep.Name == "" { + testStep.Name = overriddenStep.Name + } + // merge & override request + testStep.Request = overriddenStep.Request + // merge & override variables + testStep.Variables = mergeVariables(testStep.Variables, overriddenStep.Variables) + // merge & override extractors + testStep.Extract = mergeMap(testStep.Extract, overriddenStep.Extract) + // merge & override validators + testStep.Validators = mergeValidators(testStep.Validators, overriddenStep.Validators) + // merge & override setupHooks + testStep.SetupHooks = mergeSlices(testStep.SetupHooks, overriddenStep.SetupHooks) + // merge & override teardownHooks + testStep.TeardownHooks = mergeSlices(testStep.TeardownHooks, overriddenStep.TeardownHooks) +} + +// extend referenced testcase with teststep, teststep config merge and override referenced testcase config +func extendWithTestCase(testStep *TStep, overriddenTestCase *TestCase) { + // override testcase name + if testStep.Name != "" { + overriddenTestCase.Config.Name = testStep.Name + } + // merge & override variables + overriddenTestCase.Config.Variables = mergeVariables(testStep.Variables, overriddenTestCase.Config.Variables) + // merge & override extractors + overriddenTestCase.Config.Export = mergeSlices(testStep.Export, overriddenTestCase.Config.Export) +} + +var eval = goval.NewEvaluator() + +// literalEval parse string to number if possible +func literalEval(raw string) (interface{}, error) { + raw = strings.TrimSpace(raw) + + // return raw string if not number + if !regexCompileNumber.Match([]byte(raw)) { + return raw, nil + } + + // eval string to number + result, err := eval.Evaluate(raw, nil, nil) + if err != nil { + log.Error().Err(err).Msgf("[literalEval] eval %s failed", raw) + return raw, err + } + return result, nil +} + +func parseFunctionArguments(argsStr string) ([]interface{}, error) { + argsStr = strings.TrimSpace(argsStr) + if argsStr == "" { + return []interface{}{}, nil + } + + // split arguments by comma + args := strings.Split(argsStr, ",") + arguments := make([]interface{}, len(args)) + for index, arg := range args { + arg = strings.TrimSpace(arg) + if arg == "" { + continue + } + + // parse argument to number if possible + arg, err := literalEval(arg) + if err != nil { + return nil, err + } + arguments[index] = arg + } + + return arguments, nil +} + +func (p *parser) parseVariables(variables map[string]interface{}) (map[string]interface{}, error) { + parsedVariables := make(map[string]interface{}) + var traverseRounds int + + for len(parsedVariables) != len(variables) { + for varName, varValue := range variables { + // skip parsed variables + if _, ok := parsedVariables[varName]; ok { + continue + } + + // extract variables from current value + extractVarsSet := extractVariables(varValue) + + // check if reference variable itself + // e.g. + // variables = {"token": "abc$token"} + // variables = {"key": ["$key", 2]} + if _, ok := extractVarsSet[varName]; ok { + log.Error().Interface("variables", variables).Msg("[parseVariables] variable self reference error") + return variables, fmt.Errorf("variable self reference: %v", varName) + } + + // check if reference variable not in variables mapping + // e.g. + // {"varA": "123$varB", "varB": "456$varC"} => $varC not defined + // {"varC": "${sum_two($a, $b)}"} => $a, $b not defined + var undefinedVars []string + for extractVar := range extractVarsSet { + if _, ok := variables[extractVar]; !ok { // not in variables mapping + undefinedVars = append(undefinedVars, extractVar) + } + } + if len(undefinedVars) > 0 { + log.Error().Interface("undefinedVars", undefinedVars).Msg("[parseVariables] variable not defined error") + return variables, fmt.Errorf("variable not defined: %v", undefinedVars) + } + + parsedValue, err := p.parseData(varValue, parsedVariables) + if err != nil { + continue + } + parsedVariables[varName] = parsedValue + } + traverseRounds += 1 + // check if circular reference exists + if traverseRounds > len(variables) { + log.Error().Msg("[parseVariables] circular reference error, break infinite loop!") + return variables, fmt.Errorf("circular reference") + } + } + + return parsedVariables, nil +} + +type variableSet map[string]struct{} + +func extractVariables(raw interface{}) variableSet { + rawValue := reflect.ValueOf(raw) + switch rawValue.Kind() { + case reflect.String: + return findallVariables(rawValue.String()) + case reflect.Slice: + varSet := make(variableSet) + for i := 0; i < rawValue.Len(); i++ { + for extractVar := range extractVariables(rawValue.Index(i).Interface()) { + varSet[extractVar] = struct{}{} + } + } + return varSet + case reflect.Map: + varSet := make(variableSet) + for _, key := range rawValue.MapKeys() { + value := rawValue.MapIndex(key) + for extractVar := range extractVariables(value.Interface()) { + varSet[extractVar] = struct{}{} + } + } + return varSet + default: + // other types, e.g. nil, int, float, bool + return make(variableSet) + } +} + +func findallVariables(raw string) variableSet { + matchStartPosition := 0 + remainedString := raw + varSet := make(variableSet) + + for matchStartPosition < len(raw) { + // locate $ char position + startPosition := strings.Index(remainedString, "$") + if startPosition == -1 { // no $ found + return varSet + } + + // found $, check if variable or function + matchStartPosition += startPosition + remainedString = remainedString[startPosition:] + + // Notice: notation priority + // $$ > $var + + // search $$, use $$ to escape $ notation + if strings.HasPrefix(remainedString, "$$") { // found $$ + matchStartPosition += 2 + remainedString = remainedString[2:] + continue + } + + // search variable like ${var} or $var + varMatched := regexCompileVariable.FindStringSubmatch(remainedString) + if len(varMatched) == 3 { + var varName string + if varMatched[1] != "" { + varName = varMatched[1] // match ${var} + } else { + varName = varMatched[2] // match $var + } + varSet[varName] = struct{}{} + + matchStartPosition += len(varMatched[0]) + remainedString = raw[matchStartPosition:] + continue + } + + break + } + + return varSet +} + +func genCartesianProduct(paramsMap map[string]paramsType) paramsType { + if len(paramsMap) == 0 { + return nil + } + var params []paramsType + for _, v := range paramsMap { + params = append(params, v) + } + var cartesianProduct paramsType + cartesianProduct = params[0] + for i := 0; i < len(params)-1; i++ { + var tempProduct paramsType + for _, param1 := range cartesianProduct { + for _, param2 := range params[i+1] { + tempProduct = append(tempProduct, mergeVariables(param1, param2)) + } + } + cartesianProduct = tempProduct + } + return cartesianProduct +} + +func parseParameters(parameters map[string]interface{}, variablesMapping map[string]interface{}) (map[string]paramsType, error) { + if len(parameters) == 0 { + return nil, nil + } + parsedParametersSlice := make(map[string]paramsType) + var err error + for k, v := range parameters { + var parameterSlice paramsType + rawValue := reflect.ValueOf(v) + switch rawValue.Kind() { + case reflect.String: + // e.g. username-password: ${parameterize(examples/hrp/account.csv)} -> [{"username": "test1", "password": "111111"}, {"username": "test2", "password": "222222"}] + var parsedParameterContent interface{} + parsedParameterContent, err = newParser().parseString(rawValue.String(), variablesMapping) + if err != nil { + log.Error().Interface("parameterContent", rawValue).Msg("[parseParameters] parse parameter content error") + return nil, err + } + parsedParameterRawValue := reflect.ValueOf(parsedParameterContent) + if parsedParameterRawValue.Kind() != reflect.Slice { + log.Error().Interface("parameterContent", parsedParameterRawValue).Msg("[parseParameters] parsed parameter content should be slice") + return nil, errors.New("parsed parameter content should be slice") + } + parameterSlice, err = parseSlice(k, parsedParameterRawValue.Interface()) + case reflect.Slice: + // e.g. user_agent: ["iOS/10.1", "iOS/10.2"] -> [{"user_agent": "iOS/10.1"}, {"user_agent": "iOS/10.2"}] + parameterSlice, err = parseSlice(k, rawValue.Interface()) + default: + log.Error().Interface("parameter", parameters).Msg("[parseParameters] parameter content should be slice or text(functions call)") + return nil, errors.New("parameter content should be slice or text(functions call)") + } + if err != nil { + return nil, err + } + parsedParametersSlice[k] = parameterSlice + } + return parsedParametersSlice, nil +} + +func parseSlice(parameterName string, parameterContent interface{}) ([]map[string]interface{}, error) { + parameterNameSlice := strings.Split(parameterName, "-") + var parameterSlice []map[string]interface{} + parameterContentSlice := reflect.ValueOf(parameterContent) + if parameterContentSlice.Kind() != reflect.Slice { + return nil, errors.New("parameterContent should be slice") + } + for i := 0; i < parameterContentSlice.Len(); i++ { + parameterMap := make(map[string]interface{}) + elem := reflect.ValueOf(parameterContentSlice.Index(i).Interface()) + switch elem.Kind() { + case reflect.Map: + // e.g. "username-password": [{"username": "test1", "password": "passwd1", "other": "111"}, {"username": "test2", "password": "passwd2", "other": ""222}] + // -> [{"username": "test1", "password": "passwd1"}, {"username": "test2", "password": "passwd2"}] + for _, key := range parameterNameSlice { + if _, ok := elem.Interface().(map[string]interface{})[key]; ok { + parameterMap[key] = elem.MapIndex(reflect.ValueOf(key)).Interface() + } else { + log.Error().Interface("parameterNameSlice", parameterNameSlice).Msg("[parseParameters] parameter name not found") + return nil, errors.New("parameter name not found") + } + } + case reflect.Slice: + // e.g. "username-password": [["test1", "passwd1"], ["test2", "passwd2"]] + // -> [{"username": "test1", "password": "passwd1"}, {"username": "test2", "password": "passwd2"}] + if len(parameterNameSlice) != elem.Len() { + log.Error().Interface("parameterNameSlice", parameterNameSlice).Interface("parameterContent", elem.Interface()).Msg("[parseParameters] parameter name slice and parameter content slice should have the same length") + return nil, errors.New("parameter name slice and parameter content slice should have the same length") + } else { + for j := 0; j < elem.Len(); j++ { + parameterMap[parameterNameSlice[j]] = elem.Index(j).Interface() + } + } + default: + // e.g. "app_version": [3.1, 3.0] + // -> [{"app_version": 3.1}, {"app_version": 3.0}] + if len(parameterNameSlice) != 1 { + log.Error().Interface("parameterNameSlice", parameterNameSlice).Msg("[parseParameters] parameter name slice should have only one element when parameter content is string") + return nil, errors.New("parameter name slice should have only one element when parameter content is string") + } + parameterMap[parameterNameSlice[0]] = elem.Interface() + } + parameterSlice = append(parameterSlice, parameterMap) + } + return parameterSlice, nil +} + +func initParameterIterator(cfg *TConfig, mode string) (err error) { + var parameters map[string]paramsType + parameters, err = parseParameters(cfg.Parameters, cfg.Variables) + if err != nil { + return err + } + // parse config parameters setting + if cfg.ParametersSetting == nil { + cfg.ParametersSetting = &TParamsConfig{Iterators: []*Iterator{}} + } + // boomer模式下不限制迭代次数 + if mode == "boomer" { + cfg.ParametersSetting.Iteration = -1 + } + rawValue := reflect.ValueOf(cfg.ParametersSetting.Strategy) + switch rawValue.Kind() { + case reflect.Map: + // strategy: {"user_agent": "sequential", "username-password": "random"}, 每个参数对应一个迭代器,每个迭代器随机、顺序选取元素互不影响 + for k, v := range parameters { + if _, ok := rawValue.Interface().(map[string]interface{})[k]; ok { + // use strategy if configured + cfg.ParametersSetting.Iterators = append( + cfg.ParametersSetting.Iterators, + newIterator(v, rawValue.MapIndex(reflect.ValueOf(k)).Interface().(string), cfg.ParametersSetting.Iteration), + ) + } else { + // use sequential strategy by default + cfg.ParametersSetting.Iterators = append( + cfg.ParametersSetting.Iterators, + newIterator(v, strategySequential, cfg.ParametersSetting.Iteration), + ) + } + } + case reflect.String: + // strategy: random, 仅生成一个的迭代器,该迭代器在参数笛卡尔积slice中随机选取元素 + if len(rawValue.String()) == 0 { + cfg.ParametersSetting.Strategy = strategySequential + } else { + cfg.ParametersSetting.Strategy = strings.ToLower(rawValue.String()) + } + cfg.ParametersSetting.Iterators = append( + cfg.ParametersSetting.Iterators, + newIterator(genCartesianProduct(parameters), cfg.ParametersSetting.Strategy.(string), cfg.ParametersSetting.Iteration), + ) + default: + // default strategy: sequential, 仅生成一个的迭代器,该迭代器在参数笛卡尔积slice中顺序选取元素 + cfg.ParametersSetting.Strategy = strategySequential + cfg.ParametersSetting.Iterators = append( + cfg.ParametersSetting.Iterators, + newIterator(genCartesianProduct(parameters), cfg.ParametersSetting.Strategy.(string), cfg.ParametersSetting.Iteration), + ) + } + return nil +} + +func newIterator(parameters paramsType, strategy string, iteration int) *Iterator { + iter := parameters.Iterator() + iter.strategy = strategy + if iteration > 0 { + iter.iteration = iteration + } else if iteration < 0 { + iter.iteration = -1 + } else if iter.iteration == 0 { + iter.iteration = 1 + } + return iter +} diff --git a/hrp/parser_test.go b/hrp/parser_test.go new file mode 100644 index 00000000..73a7bbd7 --- /dev/null +++ b/hrp/parser_test.go @@ -0,0 +1,866 @@ +package hrp + +import ( + "sort" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestBuildURL(t *testing.T) { + var url string + url = buildURL("https://postman-echo.com", "/get") + if url != "https://postman-echo.com/get" { + t.Fatalf("buildURL error, %s != 'https://postman-echo.com/get'", url) + } + + url = buildURL("https://postman-echo.com/abc/", "/get?a=1&b=2") + if url != "https://postman-echo.com/get?a=1&b=2" { + t.Fatalf("buildURL error, %s != 'https://postman-echo.com/get'", url) + } + + url = buildURL("", "https://postman-echo.com/get") + if url != "https://postman-echo.com/get" { + t.Fatalf("buildURL error, %s != 'https://postman-echo.com/get'", url) + } + + // notice: step request url > config base url + url = buildURL("https://postman-echo.com", "https://httpbin.org/get") + if url != "https://httpbin.org/get" { + t.Fatalf("buildURL error, %s != 'https://httpbin.org/get'", url) + } +} + +func TestRegexCompileVariable(t *testing.T) { + testData := []string{ + "$var1", + "${var1}", + "$v", + "var_1$_v", + "${var_1}#XYZ", + "func1($var_1, $var_3)", + } + + for _, expr := range testData { + varMatched := regexCompileVariable.FindStringSubmatch(expr) + if !assert.Len(t, varMatched, 3) { + t.Fail() + } + } +} + +func TestRegexCompileAbnormalVariable(t *testing.T) { + testData := []string{ + "var1", + "${var1", + "$123", + "var_1$", + "func1($123, var_3)", + } + + for _, expr := range testData { + varMatched := regexCompileVariable.FindStringSubmatch(expr) + if !assert.Len(t, varMatched, 0) { + t.Fail() + } + } +} + +func TestRegexCompileFunction(t *testing.T) { + testData := []string{ + "${func1()}", + "${func1($a)}", + "${func1($a, $b)}", + "${func1($a, 123)}", + "${func1(123, $b)}", + "abc${func1(123, $b)}123", + } + + for _, expr := range testData { + varMatched := regexCompileFunction.FindStringSubmatch(expr) + if !assert.Len(t, varMatched, 3) { + t.Fail() + } + } +} + +func TestRegexCompileAbnormalFunction(t *testing.T) { + testData := []string{ + "${func1()", + "${func1(}", + "${func1)}", + "$func1()}", + "${1func1()}", // function name can not start with number + "${func1($a}", + "abc$func1(123, $b)}123", + // "${func1($a $b)}", + // "${func1($a, $123)}", + // "${func1(123 $b)}", + } + + for _, expr := range testData { + varMatched := regexCompileFunction.FindStringSubmatch(expr) + if !assert.Len(t, varMatched, 0) { + t.Fail() + } + } +} + +func TestParseDataStringWithVariables(t *testing.T) { + variablesMapping := map[string]interface{}{ + "var_1": "abc", + "var_2": "def", + "var_3": 123, + "var_4": map[string]interface{}{"a": 1}, + "var_5": true, + "var_6": nil, + "v": 4.5, // variable name with one character + "_v": 6.9, // variable name starts with underscore + } + + testData := []struct { + expr string + expect interface{} + }{ + // no variable + {"var_1", "var_1"}, + // single variable + {"$var_1", "abc"}, + {"${var_1}", "abc"}, + {"$var_3", 123}, + {"$var_4", map[string]interface{}{"a": 1}}, + {"${var_4}", map[string]interface{}{"a": 1}}, + {"$var_5", true}, + {"$var_6", nil}, + {"$v", 4.5}, + {"var_1$_v", "var_16.9"}, + // single variable with prefix or suffix + {"$var_1#XYZ", "abc#XYZ"}, + {"${var_1}#XYZ", "abc#XYZ"}, + {"ABC$var_1", "ABCabc"}, + {"ABC${var_1}", "ABCabc"}, + {"ABC$var_1/", "ABCabc/"}, + {"ABC${v}/", "ABC4.5/"}, + // multiple variables + {"/$var_1/$var_2/var3", "/abc/def/var3"}, + {"/${var_1}/$var_2/var3", "/abc/def/var3"}, + {"ABC$var_1$var_3", "ABCabc123"}, + {"ABC$var_1${var_3}", "ABCabc123"}, + {"ABC$var_1/$var_3", "ABCabc/123"}, + {"ABC${var_1}/${var_3}", "ABCabc/123"}, + {"ABC$var_1/123$var_1/456", "ABCabc/123abc/456"}, + {"ABC$var_1/123${var_1}/456", "ABCabc/123abc/456"}, + {"ABC$var_1/$var_2/$var_1", "ABCabc/def/abc"}, + {"ABC$var_1/$var_2/${var_1}", "ABCabc/def/abc"}, + {"func1($var_1, $var_3)", "func1(abc, 123)"}, + {"func1($var_1, ${var_3})", "func1(abc, 123)"}, + // TODO: fix compatibility with python version + {"abc$var_4", "abcmap[a:1]"}, // "abc{'a': 1}" + {"abc$var_5", "abctrue"}, // "abcTrue" + } + + parser := newParser() + for _, data := range testData { + parsedData, err := parser.parseData(data.expr, variablesMapping) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expect, parsedData) { + t.Fail() + } + } +} + +func TestParseDataStringWithUndefinedVariables(t *testing.T) { + variablesMapping := map[string]interface{}{ + "var_1": "abc", + "var_2": "def", + } + + testData := []struct { + expr string + expect interface{} + }{ + {"/api/$SECRET_KEY", "/api/$SECRET_KEY"}, // raise error + } + + parser := newParser() + for _, data := range testData { + parsedData, err := parser.parseData(data.expr, variablesMapping) + if !assert.Error(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expect, parsedData) { + t.Fail() + } + } +} + +func TestParseDataStringWithVariablesAbnormal(t *testing.T) { + variablesMapping := map[string]interface{}{ + "var_1": "abc", + "var_2": "def", + "var_3": 123, + "var_4": map[string]interface{}{"a": 1}, + "var_5": true, + "var_6": nil, + "v": 4.5, // variable name with one character + "_v": 6.9, // variable name starts with underscore + } + + testData := []struct { + expr string + expect interface{} + }{ + {"$", "$"}, + {"var_1$", "var_1$"}, + {"var_1$123", "var_1$123"}, // variable should starts with a letter + {"ABC$var_1{", "ABCabc{"}, // { + {"ABC$var_1}", "ABCabc}"}, // } + {"{ABC$var_1{}a}", "{ABCabc{}a}"}, // {xx} + {"AB{C$var_1{}a}", "AB{Cabc{}a}"}, // {xx{}x} + {"ABC$$var_1{", "ABC$var_1{"}, // $$ + {"ABC$$$var_1{", "ABC$abc{"}, // $$$ + {"ABC$$$$var_1{", "ABC$$var_1{"}, // $$$$ + {"ABC$var_1${", "ABCabc${"}, // ${ + {"ABC$var_1${a", "ABCabc${a"}, // ${ + {"ABC$var_1$}a", "ABCabc$}a"}, // $} + {"ABC$var_1}{a", "ABCabc}{a"}, // }{ + {"ABC$var_1{}a", "ABCabc{}a"}, // {} + } + + parser := newParser() + for _, data := range testData { + parsedData, err := parser.parseData(data.expr, variablesMapping) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expect, parsedData) { + t.Fail() + } + } +} + +func TestParseDataMapWithVariables(t *testing.T) { + variablesMapping := map[string]interface{}{ + "var1": "foo1", + "val1": 200, + "var2": 123, // key is int + } + + testData := []struct { + expr map[string]interface{} + expect interface{} + }{ + {map[string]interface{}{"key": "$var1"}, map[string]interface{}{"key": "foo1"}}, + {map[string]interface{}{"foo1": "$val1", "foo2": "bar2"}, map[string]interface{}{"foo1": 200, "foo2": "bar2"}}, + // parse map key, key is string + {map[string]interface{}{"$var1": "$val1"}, map[string]interface{}{"foo1": 200}}, + // parse map key, key is int + {map[string]interface{}{"$var2": "$val1"}, map[string]interface{}{"123": 200}}, + } + + parser := newParser() + for _, data := range testData { + parsedData, err := parser.parseData(data.expr, variablesMapping) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expect, parsedData) { + t.Fail() + } + } +} + +func TestParseHeaders(t *testing.T) { + variablesMapping := map[string]interface{}{ + "var1": "foo1", + "val1": 200, + "var2": 123, // key is int + "val2": nil, // value is nil + } + + testData := []struct { + rawHeaders map[string]string + expectHeaders map[string]string + }{ + {map[string]string{"key": "$var1"}, map[string]string{"key": "foo1"}}, + {map[string]string{"foo1": "$val1", "foo2": "bar2"}, map[string]string{"foo1": "200", "foo2": "bar2"}}, + // parse map key, key is string + {map[string]string{"$var1": "$val1"}, map[string]string{"foo1": "200"}}, + // parse map key, key is int + {map[string]string{"$var2": "$val1"}, map[string]string{"123": "200"}}, + // parse map key & value, key is int, value is nil + {map[string]string{"$var2": "$val2"}, map[string]string{"123": ""}}, + } + + parser := newParser() + for _, data := range testData { + parsedHeaders, err := parser.parseHeaders(data.rawHeaders, variablesMapping) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expectHeaders, parsedHeaders) { + t.Fail() + } + } +} + +func TestMergeVariables(t *testing.T) { + testData := []struct { + stepVariables map[string]interface{} + configVariables map[string]interface{} + expectVariables map[string]interface{} + }{ + { + map[string]interface{}{"base_url": "$base_url", "foo1": "bar1"}, + map[string]interface{}{"base_url": "https://httpbin.org", "foo1": "bar111"}, + map[string]interface{}{"base_url": "https://httpbin.org", "foo1": "bar1"}, + }, + { + map[string]interface{}{"n": 3, "b": 34.5, "varFoo2": "${max($a, $b)}"}, + map[string]interface{}{"n": 5, "a": 12.3, "b": 3.45, "varFoo1": "7a6K3", "varFoo2": 12.3}, + map[string]interface{}{"n": 3, "a": 12.3, "b": 34.5, "varFoo1": "7a6K3", "varFoo2": "${max($a, $b)}"}, + }, + } + + for _, data := range testData { + mergedVariables := mergeVariables(data.stepVariables, data.configVariables) + if !assert.Equal(t, data.expectVariables, mergedVariables) { + t.Fail() + } + } +} + +func TestMergeMap(t *testing.T) { + testData := []struct { + m map[string]string + overriddenMap map[string]string + expectMap map[string]string + }{ + { + map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close"}, + map[string]string{"Cache-Control": "no-cache", "Connection": "keep-alive"}, + map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close", "Cache-Control": "no-cache"}, + }, + { + map[string]string{"Host": "postman-echo.com", "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b254c2723"}, + map[string]string{"Host": "Postman-echo.com", "Connection": "keep-alive", "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b342c2723"}, + map[string]string{"Host": "postman-echo.com", "Postman-Token": "ea19464c-ddd4-4724-abe9-5e2b254c2723", "Connection": "keep-alive"}, + }, + { + map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close"}, + nil, + map[string]string{"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "Connection": "close"}, + }, + { + nil, + map[string]string{"Cache-Control": "no-cache", "Connection": "keep-alive"}, + map[string]string{"Cache-Control": "no-cache", "Connection": "keep-alive"}, + }, + } + + for _, data := range testData { + mergedMap := mergeMap(data.m, data.overriddenMap) + if !assert.Equal(t, data.expectMap, mergedMap) { + t.Fail() + } + } +} + +func TestMergeSlices(t *testing.T) { + testData := []struct { + slice []string + overriddenSlice []string + expectSlice []string + }{ + { + []string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}"}, + []string{"${setup_hook_example3($name)}", "${setup_hook_example4($name)}"}, + []string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}", "${setup_hook_example3($name)}", "${setup_hook_example4($name)}"}, + }, + { + []string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}"}, + nil, + []string{"${setup_hook_example1($name)}", "${setup_hook_example2($name)}"}, + }, + { + nil, + []string{"${setup_hook_example3($name)}", "${setup_hook_example4($name)}"}, + []string{"${setup_hook_example3($name)}", "${setup_hook_example4($name)}"}, + }, + } + + for _, data := range testData { + mergedSlice := mergeSlices(data.slice, data.overriddenSlice) + if !assert.Equal(t, data.expectSlice, mergedSlice) { + t.Fail() + } + } +} + +func TestMergeValidators(t *testing.T) { + testData := []struct { + validators []interface{} + overriddenValidators []interface{} + expectValidators []interface{} + }{ + { + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}}, + []interface{}{Validator{Check: `headers."Content-Type"`, Assert: "equals", Expect: "application/json; charset=utf-8", Message: "assert response header Content-Typ"}}, + []interface{}{ + Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}, + Validator{Check: `headers."Content-Type"`, Assert: "equals", Expect: "application/json; charset=utf-8", Message: "assert response header Content-Typ"}, + }, + }, + { + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}}, + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}}, + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}}, + }, + { + nil, + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}}, + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 200, Message: "assert response status code"}}, + }, + { + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}}, + nil, + []interface{}{Validator{Check: "status_code", Assert: "equals", Expect: 302, Message: "assert response status code"}}, + }, + } + + for _, data := range testData { + mergedValidators := mergeValidators(data.validators, data.overriddenValidators) + if !assert.Equal(t, data.expectValidators, mergedValidators) { + t.Fail() + } + } +} + +func TestCallBuiltinFunction(t *testing.T) { + parser := newParser() + + // call function without arguments + _, err := parser.callFunc("get_timestamp") + if !assert.NoError(t, err) { + t.Fail() + } + + // call function with one argument + timeStart := time.Now() + _, err = parser.callFunc("sleep", 1) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Greater(t, time.Since(timeStart), time.Duration(1)*time.Second) { + t.Fail() + } + + // call function with one argument + result, err := parser.callFunc("gen_random_string", 10) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, 10, len(result.(string))) { + t.Fail() + } + + // call function with two argument + result, err = parser.callFunc("max", float64(10), 9.99) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, float64(10), result.(float64)) { + t.Fail() + } +} + +func TestLiteralEval(t *testing.T) { + testData := []struct { + expr string + expect interface{} + }{ + {"123", 123}, + {"1.23", 1.23}, + {"-123", -123}, + {"-1.23", -1.23}, + {"abc", "abc"}, + {" a bc ", "a bc"}, + {" a $bc ", "a $bc"}, + {"$var", "$var"}, + {" $var ", "$var"}, + {" $var1 ", "$var1"}, + {"", ""}, + } + + for _, data := range testData { + value, err := literalEval(data.expr) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expect, value) { + t.Fail() + } + } +} + +func TestParseFunctionArguments(t *testing.T) { + testData := []struct { + expr string + expect interface{} + }{ + {"", []interface{}{}}, + {"123", []interface{}{123}}, + {"1.23", []interface{}{1.23}}, + {"-123", []interface{}{-123}}, + {"-1.23", []interface{}{-1.23}}, + {"abc", []interface{}{"abc"}}, + {"$var", []interface{}{"$var"}}, + {"1,2", []interface{}{1, 2}}, + {"1,2.3", []interface{}{1, 2.3}}, + {"1, -2.3", []interface{}{1, -2.3}}, + {"1,,2", []interface{}{1, nil, 2}}, + {" $var1 , 2 ", []interface{}{"$var1", 2}}, + } + + for _, data := range testData { + value, err := parseFunctionArguments(data.expr) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expect, value) { + t.Fail() + } + } +} + +func TestParseDataStringWithFunctions(t *testing.T) { + variablesMapping := map[string]interface{}{ + "n": 5, + "a": 12.3, + "b": 3.45, + } + + testData1 := []struct { + expr string + expect interface{} + }{ + {"${gen_random_string(5)}", 5}, + {"${gen_random_string($n)}", 5}, + {"123${gen_random_string(5)}abc", 11}, + {"123${gen_random_string($n)}abc", 11}, + } + + parser := newParser() + for _, data := range testData1 { + value, err := parser.parseData(data.expr, variablesMapping) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expect, len(value.(string))) { + t.Fail() + } + } + + testData2 := []struct { + expr string + expect interface{} + }{ + {"${max($a, $b)}", 12.3}, + {"abc${max($a, $b)}123", "abc12.3123"}, + {"abc${max($a, 3.45)}123", "abc12.3123"}, + } + + for _, data := range testData2 { + value, err := parser.parseData(data.expr, variablesMapping) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expect, value) { + t.Fail() + } + } +} + +func TestConvertString(t *testing.T) { + testData := []struct { + raw interface{} + expect interface{} + }{ + {"", ""}, + {"abc", "abc"}, + {"123", "123"}, + {123, "123"}, + {1.23, "1.23"}, + {nil, ""}, + } + + for _, data := range testData { + value := convertString(data.raw) + if !assert.Equal(t, data.expect, value) { + t.Fail() + } + } +} + +func TestParseVariables(t *testing.T) { + testData := []struct { + rawVars map[string]interface{} + expectVars map[string]interface{} + }{ + { + map[string]interface{}{"varA": "$varB", "varB": "$varC", "varC": "123", "a": 1, "b": 2}, + map[string]interface{}{"varA": "123", "varB": "123", "varC": "123", "a": 1, "b": 2}, + }, + { + map[string]interface{}{"n": 34.5, "a": 12.3, "b": "$n", "varFoo2": "${max($a, $b)}"}, + map[string]interface{}{"n": 34.5, "a": 12.3, "b": 34.5, "varFoo2": 34.5}, + }, + } + + parser := newParser() + for _, data := range testData { + value, err := parser.parseVariables(data.rawVars) + if !assert.NoError(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expectVars, value) { + t.Fail() + } + } +} + +func TestParseVariablesAbnormal(t *testing.T) { + testData := []struct { + rawVars map[string]interface{} + expectVars map[string]interface{} + }{ + { // self referenced variable $varA + map[string]interface{}{"varA": "$varA"}, + map[string]interface{}{"varA": "$varA"}, + }, + { // undefined variable $varC + map[string]interface{}{"varA": "$varB", "varB": "$varC", "a": 1, "b": 2}, + map[string]interface{}{"varA": "$varB", "varB": "$varC", "a": 1, "b": 2}, + }, + { // circular reference + map[string]interface{}{"varA": "$varB", "varB": "$varA"}, + map[string]interface{}{"varA": "$varB", "varB": "$varA"}, + }, + } + + parser := newParser() + for _, data := range testData { + value, err := parser.parseVariables(data.rawVars) + if !assert.Error(t, err) { + t.Fail() + } + if !assert.Equal(t, data.expectVars, value) { + t.Fail() + } + } +} + +func TestExtractVariables(t *testing.T) { + testData := []struct { + raw interface{} + expectVars []string + }{ + {nil, nil}, + {"/$var1/$var1", []string{"var1"}}, + { + map[string]interface{}{"varA": "$varB", "varB": "$varC", "varC": "123"}, + []string{"varB", "varC"}, + }, + { + []interface{}{"varA", "$varB", 123, "$varC", "123"}, + []string{"varB", "varC"}, + }, + { // nested map and slice + map[string]interface{}{"varA": "$varB", "varB": map[string]interface{}{"C": "$varC", "D": []string{"$varE"}}}, + []string{"varB", "varC", "varE"}, + }, + } + + for _, data := range testData { + var varList []string + for varName := range extractVariables(data.raw) { + varList = append(varList, varName) + } + sort.Strings(varList) + if !assert.Equal(t, data.expectVars, varList) { + t.Fail() + } + } +} + +func TestFindallVariables(t *testing.T) { + testData := []struct { + raw string + expectVars []string + }{ + {"", nil}, + {"$variable", []string{"variable"}}, + {"${variable}123", []string{"variable"}}, + {"/blog/$postid", []string{"postid"}}, + {"/$var1/$var2", []string{"var1", "var2"}}, + {"/$var1/$var1", []string{"var1"}}, + {"abc", nil}, + {"Z:2>1*0*1+1$a", []string{"a"}}, + {"Z:2>1*0*1+1$$a", nil}, + {"Z:2>1*0*1+1$$$a", []string{"a"}}, + {"Z:2>1*0*1+1$$$$a", nil}, + {"Z:2>1*0*1+1$$a$b", []string{"b"}}, + {"Z:2>1*0*1+1$$a$$b", nil}, + {"Z:2>1*0*1+1$a$b", []string{"a", "b"}}, + {"Z:2>1*0*1+1$$1", nil}, + {"a$var", []string{"var"}}, + {"a$v b", []string{"v"}}, + {"${func()}", nil}, + {"a${func(1,2)}b", nil}, + {"${gen_md5($TOKEN, $data, $random)}", []string{"TOKEN", "data", "random"}}, + } + + for _, data := range testData { + var varList []string + for varName := range findallVariables(data.raw) { + varList = append(varList, varName) + } + sort.Strings(varList) + if !assert.Equal(t, data.expectVars, varList) { + t.Fail() + } + } +} + +func TestParseParameters(t *testing.T) { + testData := []struct { + rawVars map[string]interface{} + expectLength int + }{ + { + map[string]interface{}{ + "username-password": "${parameterize(../examples/hrp/account.csv)}", + "user_agent": []interface{}{"IOS/10.1", "IOS/10.2"}}, + 6, + }, + { + map[string]interface{}{ + "username-password": [][]interface{}{{"test1", "111111"}, {"test2", "222222"}, {"test3", "333333"}}, + "user_agent": []interface{}{"IOS/10.1", "IOS/10.2"}, + "app_version": []interface{}{0.3}}, + 6, + }, + { + map[string]interface{}{ + "username-password": [][]interface{}{{"test1", "111111"}, {"test2", "222222"}, {"test3", "333333"}}, + "user_agent": []interface{}{"IOS/10.1", "IOS/10.2"}, + "app_version": []interface{}{0.3, 0.4, 0.5}}, + 18, + }, + { + map[string]interface{}{}, 0, + }, + { + nil, 0, + }, + } + for _, data := range testData { + params, _ := parseParameters(data.rawVars, map[string]interface{}{}) + value := genCartesianProduct(params) + if !assert.Len(t, value, data.expectLength) { + t.Fail() + } + } +} + +func TestParseParametersError(t *testing.T) { + testData := []struct { + rawVars map[string]interface{} + }{ + { + map[string]interface{}{ + "username_password": "${parameterize(../examples/hrp/account.csv)}", + "user_agent": []interface{}{"IOS/10.1", "IOS/10.2"}}, + }, + { + map[string]interface{}{ + "username-password": "${parameterize(../examples/hrp/account.csv)}", + "user-agent": []interface{}{"IOS/10.1", "IOS/10.2"}}, + }, + { + map[string]interface{}{ + "username-password": "${param(../examples/hrp/account.csv)}", + "user_agent": []interface{}{"IOS/10.1", "IOS/10.2"}}, + }, + } + for _, data := range testData { + _, err := parseParameters(data.rawVars, map[string]interface{}{}) + if !assert.Error(t, err) { + t.Fail() + } + } +} + +func TestParseSlice(t *testing.T) { + testData := []struct { + rawVar1 string + rawVar2 interface{} + expect []map[string]interface{} + }{ + { + "username-password", + []map[string]interface{}{{"username": "test1", "password": 111111, "other": "111"}, {"username": "test2", "password": 222222, "other": "222"}}, + []map[string]interface{}{ + {"username": "test1", "password": 111111}, + {"username": "test2", "password": 222222}, + }, + }, + { + "username-password", + [][]string{{"test1", "111111"}, {"test2", "222222"}}, + []map[string]interface{}{ + {"username": "test1", "password": "111111"}, + {"username": "test2", "password": "222222"}, + }, + }, + { + "app_version", + []float64{3.1, 3.0}, + []map[string]interface{}{ + {"app_version": 3.1}, + {"app_version": 3.0}, + }, + }, + } + for _, data := range testData { + value, _ := parseSlice(data.rawVar1, data.rawVar2) + if !assert.Equal(t, data.expect, value) { + t.Fail() + } + } +} + +func TestParseSliceError(t *testing.T) { + testData := []struct { + rawVar1 string + rawVar2 interface{} + }{ + { + "app_version", + 123, + }, + { + "app_version", + "123", + }, + } + for _, data := range testData { + _, err := parseSlice(data.rawVar1, data.rawVar2) + if !assert.Error(t, err) { + t.Fail() + } + } +} diff --git a/hrp/plugin.go b/hrp/plugin.go new file mode 100644 index 00000000..f7e72900 --- /dev/null +++ b/hrp/plugin.go @@ -0,0 +1,116 @@ +package hrp + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/httprunner/funplugin" + "github.com/httprunner/httprunner/hrp/internal/ga" + "github.com/rs/zerolog/log" +) + +const ( + goPluginFile = "debugtalk.so" // built from go plugin + hashicorpGoPluginFile = "debugtalk.bin" // built from hashicorp go plugin + hashicorpPyPluginFile = "debugtalk.py" // used for hashicorp python plugin +) + +func initPlugin(path string, logOn bool) (plugin funplugin.IPlugin, err error) { + // plugin file not found + if path == "" { + return nil, nil + } + pluginPath, err := locatePlugin(path) + if err != nil { + return nil, nil + } + + // found plugin file + plugin, err = funplugin.Init(pluginPath, funplugin.WithLogOn(logOn)) + if err != nil { + log.Error().Err(err).Msgf("init plugin failed: %s", pluginPath) + return + } + + // catch Interrupt and SIGTERM signals to ensure plugin quitted + c := make(chan os.Signal) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + <-c + plugin.Quit() + }() + + // report event for initializing plugin + event := ga.EventTracking{ + Category: "InitPlugin", + Action: fmt.Sprintf("Init %s plugin", plugin.Type()), + Value: 0, // success + } + if err != nil { + event.Value = 1 // failed + } + go ga.SendEvent(event) + + return +} + +func locatePlugin(path string) (pluginPath string, err error) { + // priority: hashicorp plugin (debugtalk.bin > debugtalk.py) > go plugin (debugtalk.so) + + pluginPath, err = locateFile(path, hashicorpGoPluginFile) + if err == nil { + return + } + + pluginPath, err = locateFile(path, hashicorpPyPluginFile) + if err == nil { + return + } + + pluginPath, err = locateFile(path, goPluginFile) + if err == nil { + return + } + + return "", fmt.Errorf("plugin file not found") +} + +// locateFile searches destFile upward recursively until current +// working directory or system root dir. +func locateFile(startPath string, destFile string) (string, error) { + stat, err := os.Stat(startPath) + if os.IsNotExist(err) { + return "", err + } + + var startDir string + if stat.IsDir() { + startDir = startPath + } else { + startDir = filepath.Dir(startPath) + } + startDir, _ = filepath.Abs(startDir) + + // convention over configuration + pluginPath := filepath.Join(startDir, destFile) + if _, err := os.Stat(pluginPath); err == nil { + return pluginPath, nil + } + + // current working directory + cwd, _ := os.Getwd() + if startDir == cwd { + return "", fmt.Errorf("searched to CWD, plugin file not found") + } + + // system root dir + parentDir, _ := filepath.Abs(filepath.Dir(startDir)) + if parentDir == startDir { + return "", fmt.Errorf("searched to system root dir, plugin file not found") + } + + return locateFile(parentDir, destFile) +} diff --git a/hrp/plugin_test.go b/hrp/plugin_test.go new file mode 100644 index 00000000..2016ef5b --- /dev/null +++ b/hrp/plugin_test.go @@ -0,0 +1,62 @@ +package hrp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLocateFile(t *testing.T) { + // specify target file path + _, err := locateFile("../examples/hrp/plugin/debugtalk.go", "debugtalk.go") + if !assert.Nil(t, err) { + t.Fail() + } + + // specify path with the same dir + _, err = locateFile("../examples/hrp/plugin/hashicorp.go", "debugtalk.go") + if !assert.Nil(t, err) { + t.Fail() + } + + // specify target file path dir + _, err = locateFile("../examples/hrp/plugin/", "debugtalk.go") + if !assert.Nil(t, err) { + t.Fail() + } + + // specify wrong path + _, err = locateFile("../examples/hrp", "debugtalk.go") + if !assert.Error(t, err) { + t.Fail() + } + _, err = locateFile("../examples/hrp/demo.json", "debugtalk.go") + if !assert.Error(t, err) { + t.Fail() + } + _, err = locateFile(".", "debugtalk.go") + if !assert.Error(t, err) { + t.Fail() + } + _, err = locateFile("/abc", "debugtalk.go") + if !assert.Error(t, err) { + t.Fail() + } +} + +func TestLocatePythonPlugin(t *testing.T) { + _, err := locatePlugin("../examples/hrp/debugtalk.py") + if !assert.Nil(t, err) { + t.Fail() + } +} + +func TestLocateGoPlugin(t *testing.T) { + buildHashicorpPlugin() + defer removeHashicorpPlugin() + + _, err := locatePlugin("../examples/hrp/debugtalk.bin") + if !assert.Nil(t, err) { + t.Fail() + } +} diff --git a/hrp/response.go b/hrp/response.go new file mode 100644 index 00000000..fb6d49c3 --- /dev/null +++ b/hrp/response.go @@ -0,0 +1,226 @@ +package hrp + +import ( + "bytes" + builtinJSON "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/jmespath/go-jmespath" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/hrp/internal/json" +) + +func newResponseObject(t *testing.T, parser *parser, resp *http.Response) (*responseObject, error) { + // prepare response headers + headers := make(map[string]string) + for k, v := range resp.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + + // prepare response cookies + cookies := make(map[string]string) + for _, cookie := range resp.Cookies() { + cookies[cookie.Name] = cookie.Value + } + + // read response body + respBodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // parse response body + var body interface{} + if err := json.Unmarshal(respBodyBytes, &body); err != nil { + // response body is not json, use raw body + body = string(respBodyBytes) + } + + respObjMeta := respObjMeta{ + StatusCode: resp.StatusCode, + Headers: headers, + Cookies: cookies, + Body: body, + } + + // convert respObjMeta to interface{} + respObjMetaBytes, _ := json.Marshal(respObjMeta) + var data interface{} + decoder := json.NewDecoder(bytes.NewReader(respObjMetaBytes)) + decoder.UseNumber() + if err := decoder.Decode(&data); err != nil { + log.Error(). + Str("respObjMeta", string(respObjMetaBytes)). + Err(err). + Msg("[NewResponseObject] convert respObjMeta to interface{} failed") + return nil, err + } + + return &responseObject{ + t: t, + parser: parser, + respObjMeta: data, + }, nil +} + +type respObjMeta struct { + StatusCode int `json:"status_code"` + Headers map[string]string `json:"headers"` + Cookies map[string]string `json:"cookies"` + Body interface{} `json:"body"` +} + +type responseObject struct { + t *testing.T + parser *parser + respObjMeta interface{} + validationResults []*validationResult +} + +const textExtractorSubRegexp string = `(.*)` + +func (v *responseObject) extractField(value string) interface{} { + var result interface{} + if strings.Contains(value, textExtractorSubRegexp) { + result = v.searchRegexp(value) + } else { + result = v.searchJmespath(value) + } + return result +} + +func (v *responseObject) Extract(extractors map[string]string) map[string]interface{} { + if extractors == nil { + return nil + } + + extractMapping := make(map[string]interface{}) + for key, value := range extractors { + extractedValue := v.extractField(value) + log.Info().Str("from", value).Interface("value", extractedValue).Msg("extract value") + log.Info().Str("variable", key).Interface("value", extractedValue).Msg("set variable") + extractMapping[key] = extractedValue + } + + return extractMapping +} + +func (v *responseObject) Validate(iValidators []interface{}, variablesMapping map[string]interface{}) (err error) { + for _, iValidator := range iValidators { + validator, ok := iValidator.(Validator) + if !ok { + return errors.New("validator type error") + } + // parse check value + checkItem := validator.Check + var checkValue interface{} + if strings.Contains(checkItem, "$") { + // reference variable + checkValue, err = v.parser.parseData(checkItem, variablesMapping) + if err != nil { + return err + } + } else { + // regExp or jmesPath + checkValue = v.extractField(checkItem) + } + + // get assert method + assertMethod := validator.Assert + assertFunc, ok := builtin.Assertions[assertMethod] + if !ok { + return errors.New(fmt.Sprintf("unexpected assertMethod: %v", assertMethod)) + } + + // parse expected value + expectValue, err := v.parser.parseData(validator.Expect, variablesMapping) + if err != nil { + return err + } + validResult := &validationResult{ + Validator: Validator{ + Check: validator.Check, + Expect: expectValue, + Assert: assertMethod, + Message: validator.Message, + }, + CheckValue: checkValue, + CheckResult: "fail", + } + + // do assertion + result := assertFunc(v.t, checkValue, expectValue) + if result { + validResult.CheckResult = "pass" + } + v.validationResults = append(v.validationResults, validResult) + log.Info(). + Str("checkExpr", validator.Check). + Str("assertMethod", assertMethod). + Interface("expectValue", expectValue). + Interface("checkValue", checkValue). + Bool("result", result). + Msgf("validate %s", checkItem) + if !result { + v.t.Fail() + return errors.New(fmt.Sprintf( + "do assertion failed, checkExpr: %v, assertMethod: %v, checkValue: %v, expectValue: %v", + validator.Check, + assertMethod, + checkValue, + expectValue, + )) + } + } + return nil +} + +func (v *responseObject) searchJmespath(expr string) interface{} { + checkValue, err := jmespath.Search(expr, v.respObjMeta) + if err != nil { + log.Error().Str("expr", expr).Err(err).Msg("search jmespath failed") + return expr // jmespath not found, return the expression + } + if number, ok := checkValue.(builtinJSON.Number); ok { + checkNumber, err := parseJSONNumber(number) + if err != nil { + log.Error().Interface("json number", number).Err(err).Msg("convert json number failed") + } + return checkNumber + } + return checkValue +} + +func (v *responseObject) searchRegexp(expr string) interface{} { + respMap, ok := v.respObjMeta.(map[string]interface{}) + if !ok { + log.Error().Interface("resp", v.respObjMeta).Msg("convert respObjMeta to map failed") + return expr + } + bodyStr, ok := respMap["body"].(string) + if !ok { + log.Error().Interface("resp", respMap).Msg("convert body to string failed") + return expr + } + regexpCompile, err := regexp.Compile(expr) + if err != nil { + log.Error().Str("expr", expr).Err(err).Msg("compile expr failed") + return expr + } + match := regexpCompile.FindStringSubmatch(bodyStr) + if match != nil || len(match) > 1 { + return match[1] //return first matched result in parentheses + } + log.Error().Str("expr", expr).Msg("search regexp failed") + return expr +} diff --git a/hrp/response_test.go b/hrp/response_test.go new file mode 100644 index 00000000..cfd3143d --- /dev/null +++ b/hrp/response_test.go @@ -0,0 +1,35 @@ +package hrp + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSearchRegexp(t *testing.T) { + testText := `hrp aims to be a one-stop solution for HTTP(S) testing, covering API testing, load testing and digital experience monitoring (DEM).` + testData := []struct { + raw string + expected string + }{ + {"covering (.*) testing,", "API"}, + {" (.*) to", "aims"}, + {"^(.*) aims", "hrp"}, + {".* (.*?)$", "(DEM)."}, + } + // new response object + resp := http.Response{} + resp.Body = io.NopCloser(strings.NewReader(testText)) + respObj, err := newResponseObject(t, newParser(), &resp) + if err != nil { + t.Fail() + } + for _, data := range testData { + if !assert.Equal(t, data.expected, respObj.searchRegexp(data.raw)) { + t.Fail() + } + } +} diff --git a/hrp/runner.go b/hrp/runner.go new file mode 100644 index 00000000..a677a3ff --- /dev/null +++ b/hrp/runner.go @@ -0,0 +1,1075 @@ +package hrp + +import ( + "bufio" + "bytes" + "compress/gzip" + "compress/zlib" + "crypto/tls" + _ "embed" + "fmt" + "html/template" + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/andybalholm/brotli" + "github.com/jinzhu/copier" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + + "github.com/httprunner/httprunner/hrp/internal/builtin" + "github.com/httprunner/httprunner/hrp/internal/ga" + "github.com/httprunner/httprunner/hrp/internal/json" +) + +const ( + summaryPath string = "reports/summary-%v.json" + reportPath string = "reports/report-%v.html" +) + +// Run starts to run API test with default configs. +func Run(testcases ...ITestCase) error { + t := &testing.T{} + return NewRunner(t).SetRequestsLogOn().Run(testcases...) +} + +// NewRunner constructs a new runner instance. +func NewRunner(t *testing.T) *HRPRunner { + if t == nil { + t = &testing.T{} + } + return &HRPRunner{ + t: t, + failfast: true, // default to failfast + genHTMLReport: false, + client: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + Timeout: 30 * time.Second, + }, + } +} + +type HRPRunner struct { + t *testing.T + failfast bool + requestsLogOn bool + pluginLogOn bool + saveTests bool + genHTMLReport bool + client *http.Client +} + +// SetClientTransport configures transport of http client for high concurrency load testing +func (r *HRPRunner) SetClientTransport(maxConns int, disableKeepAlive bool, disableCompression bool) *HRPRunner { + log.Info().Int("maxConns", maxConns).Msg("[init] SetClientTransport") + r.client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + DialContext: (&net.Dialer{}).DialContext, + MaxIdleConns: 0, + MaxIdleConnsPerHost: maxConns, + DisableKeepAlives: disableKeepAlive, + DisableCompression: disableCompression, + } + return r +} + +// SetFailfast configures whether to stop running when one step fails. +func (r *HRPRunner) SetFailfast(failfast bool) *HRPRunner { + log.Info().Bool("failfast", failfast).Msg("[init] SetFailfast") + r.failfast = failfast + return r +} + +// SetRequestsLogOn turns on request & response details logging. +func (r *HRPRunner) SetRequestsLogOn() *HRPRunner { + log.Info().Msg("[init] SetRequestsLogOn") + r.requestsLogOn = true + return r +} + +// SetPluginLogOn turns on plugin logging. +func (r *HRPRunner) SetPluginLogOn() *HRPRunner { + log.Info().Msg("[init] SetPluginLogOn") + r.pluginLogOn = true + return r +} + +// SetProxyUrl configures the proxy URL, which is usually used to capture HTTP packets for debugging. +func (r *HRPRunner) SetProxyUrl(proxyUrl string) *HRPRunner { + log.Info().Str("proxyUrl", proxyUrl).Msg("[init] SetProxyUrl") + p, err := url.Parse(proxyUrl) + if err != nil { + log.Error().Err(err).Str("proxyUrl", proxyUrl).Msg("[init] invalid proxyUrl") + return r + } + r.client.Transport = &http.Transport{ + Proxy: http.ProxyURL(p), + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + return r +} + +// SetSaveTests configures whether to save summary of tests. +func (r *HRPRunner) SetSaveTests(saveTests bool) *HRPRunner { + log.Info().Bool("saveTests", saveTests).Msg("[init] SetSaveTests") + r.saveTests = saveTests + return r +} + +// GenHTMLReport configures whether to gen html report of api tests. +func (r *HRPRunner) GenHTMLReport() *HRPRunner { + log.Info().Bool("genHTMLReport", true).Msg("[init] SetgenHTMLReport") + r.genHTMLReport = true + return r +} + +// Run starts to execute one or multiple testcases. +func (r *HRPRunner) Run(testcases ...ITestCase) error { + event := ga.EventTracking{ + Category: "RunAPITests", + Action: "hrp run", + } + // report start event + go ga.SendEvent(event) + // report execution timing event + defer ga.SendEvent(event.StartTiming("execution")) + // record execution data to summary + s := newOutSummary() + for _, iTestCase := range testcases { + testcase, err := iTestCase.ToTestCase() + if err != nil { + log.Error().Err(err).Msg("[Run] convert ITestCase interface to TestCase struct failed") + return err + } + cfg := testcase.Config + // parse config parameters + err = initParameterIterator(cfg, "runner") + if err != nil { + log.Error().Interface("parameters", cfg.Parameters).Err(err).Msg("parse config parameters failed") + return err + } + // 在runner模式下,指定整体策略,cfg.ParametersSetting.Iterators仅包含一个CartesianProduct的迭代器 + for it := cfg.ParametersSetting.Iterators[0]; it.HasNext(); { + // iterate through all parameter iterators and update case variables + for _, it := range cfg.ParametersSetting.Iterators { + if it.HasNext() { + cfg.Variables = mergeVariables(it.Next(), cfg.Variables) + } + } + caseRunnerObj := r.newCaseRunner(testcase) + if err = caseRunnerObj.run(); err != nil { + log.Error().Err(err).Msg("[Run] run testcase failed") + return err + } + caseSummary := caseRunnerObj.getSummary() + s.appendCaseSummary(caseSummary) + } + } + s.Time.Duration = time.Since(s.Time.StartAt).Seconds() + // save summary + if r.saveTests { + dir, _ := filepath.Split(summaryPath) + err := builtin.EnsureFolderExists(dir) + if err != nil { + return err + } + err = builtin.Dump2JSON(s, fmt.Sprintf(summaryPath, s.Time.StartAt.Unix())) + if err != nil { + return err + } + } + // generate HTML report + if r.genHTMLReport { + err := s.genHTMLReport() + if err != nil { + return err + } + } + return nil +} + +func (r *HRPRunner) newCaseRunner(testcase *TestCase) *caseRunner { + caseRunner := &caseRunner{ + TestCase: testcase, + hrpRunner: r, + parser: newParser(), + summary: newSummary(), + } + caseRunner.reset() + return caseRunner +} + +// caseRunner is used to run testcase and its steps. +// each testcase has its own caseRunner instance and share session variables. +type caseRunner struct { + *TestCase + hrpRunner *HRPRunner + parser *parser + sessionVariables map[string]interface{} + // transactions stores transaction timing info. + // key is transaction name, value is map of transaction type and time, e.g. start time and end time. + transactions map[string]map[transactionType]time.Time + startTime time.Time // record start time of the testcase + summary *testCaseSummary // record test case summary +} + +// reset clears runner session variables. +func (r *caseRunner) reset() *caseRunner { + log.Info().Msg("[init] Reset session variables") + r.sessionVariables = make(map[string]interface{}) + r.transactions = make(map[string]map[transactionType]time.Time) + r.startTime = time.Now() + r.summary.Name = r.Config.Name + return r +} + +func (r *caseRunner) run() error { + config := r.TestCase.Config + // init plugin + var err error + if r.parser.plugin, err = initPlugin(config.Path, r.hrpRunner.pluginLogOn); err != nil { + return err + } + defer func() { + if r.parser.plugin != nil { + r.parser.plugin.Quit() + } + }() + if err := r.parseConfig(config); err != nil { + return err + } + log.Info().Str("testcase", config.Name).Msg("run testcase start") + + r.startTime = time.Now() + for index := range r.TestCase.TestSteps { + stepDataObj, err := r.runStep(index, config) + if stepDataObj == nil { + stepDataObj = &stepData{ + Name: r.TestCase.TestSteps[index].Name(), + Success: false, + } + } + if stepDataObj.StepType == stepTypeTestCase { + // merge test case if the step is test case + summary, ok := stepDataObj.Data.(*testCaseSummary) + if ok { + r.summary.Records = append(r.summary.Records, summary.Records...) + r.summary.Stat.Total += summary.Stat.Total + r.summary.Stat.Successes += summary.Stat.Successes + r.summary.Stat.Failures += summary.Stat.Failures + } + } else if stepDataObj.StepType == stepTypeRequest { + // only record that the test step is the request step + r.summary.Records = append(r.summary.Records, stepDataObj) + r.summary.Stat.Total += 1 + if stepDataObj.Success { + r.summary.Stat.Successes += 1 + } else { + r.summary.Stat.Failures += 1 + } + } + r.summary.Success = r.summary.Success && stepDataObj.Success + if err != nil { + stepDataObj.Attachment = err.Error() + if r.hrpRunner.failfast { + return errors.Wrap(err, "abort running due to failfast setting") + } + } + } + + log.Info().Str("testcase", config.Name).Msg("run testcase end") + return nil +} + +func (r *caseRunner) runStep(index int, caseConfig *TConfig) (stepResult *stepData, err error) { + step := r.TestCase.TestSteps[index] + + // step type priority order: transaction > rendezvous > thinktime > testcase > request + if stepTran, ok := step.(*StepTransaction); ok { + // transaction step + return r.runStepTransaction(stepTran.step.Transaction) + } else if stepRend, ok := step.(*StepRendezvous); ok { + // rendezvous step + return r.runStepRendezvous(stepRend.step.Rendezvous) + } else if stepThink, ok := step.(*StepThinkTime); ok { + // think time step + return r.runStepThinkTime(stepThink.step, caseConfig.ThinkTime) + } + + log.Info().Str("step", step.Name()).Msg("run step start") + + // copy step and config to avoid data racing + copiedStep := &TStep{} + if err = copier.Copy(copiedStep, step.ToStruct()); err != nil { + log.Error().Err(err).Msg("copy step data failed") + return nil, err + } + + stepVariables := copiedStep.Variables + // override variables + // step variables > session variables (extracted variables from previous steps) + stepVariables = mergeVariables(stepVariables, r.sessionVariables) + // step variables > testcase config variables + stepVariables = mergeVariables(stepVariables, caseConfig.Variables) + + // parse step variables + parsedVariables, err := r.parser.parseVariables(stepVariables) + if err != nil { + log.Error().Interface("variables", caseConfig.Variables).Err(err).Msg("parse step variables failed") + return nil, err + } + copiedStep.Variables = parsedVariables // avoid data racing + + // step type priority order: testcase > request + if _, ok := step.(*StepTestCaseWithOptionalArgs); ok { + // run referenced testcase + log.Info().Str("testcase", copiedStep.Name).Msg("run referenced testcase") + stepResult, err = r.runStepTestCase(copiedStep) + if err != nil { + log.Error().Err(err).Msg("run referenced testcase step failed") + } + } else { + if _, ok := step.(*StepAPIWithOptionalArgs); ok { + // run referenced API + log.Info().Str("api", copiedStep.Name).Msg("run referenced api") + api, _ := copiedStep.APIContent.ToAPI() + extendWithAPI(copiedStep, api) + } + // override headers + if caseConfig.Headers != nil { + copiedStep.Request.Headers = mergeMap(copiedStep.Request.Headers, caseConfig.Headers) + } + // parse step request url + var requestUrl interface{} + requestUrl, err = r.parser.parseString(copiedStep.Request.URL, copiedStep.Variables) + if err != nil { + log.Error().Err(err).Msg("parse request url failed") + requestUrl = copiedStep.Variables + } + copiedStep.Request.URL = buildURL(caseConfig.BaseURL, convertString(requestUrl)) // avoid data racing + // run request + stepResult, err = r.runStepRequest(copiedStep) + if err != nil { + log.Error().Err(err).Msg("run request step failed") + } + } + + // update extracted variables + for k, v := range stepResult.ExportVars { + r.sessionVariables[k] = v + } + + log.Info(). + Str("step", step.Name()). + Bool("success", stepResult.Success). + Interface("exportVars", stepResult.ExportVars). + Msg("run step end") + return stepResult, err +} + +func (r *caseRunner) runStepThinkTime(step *TStep, ttc *ThinkTimeConfig) (stepResult *stepData, err error) { + thinkTime := step.ThinkTime + log.Info(). + Str("name", step.Name). + Float64("time", thinkTime.Time). + Msg("think time") + stepResult = &stepData{ + Name: step.Name, + StepType: stepTypeThinkTime, + Success: true, + } + if ttc == nil { + ttc = &ThinkTimeConfig{thinkTimeDefault, nil, 0} + } + var tt time.Duration + switch ttc.Strategy { + case thinkTimeDefault: + tt = time.Duration(thinkTime.Time*1000) * time.Millisecond + case thinkTimeRandomPercentage: + m, ok := ttc.Setting.(map[string]float64) // e.g. {"min_percentage": 0.5, "max_percentage": 1.5} + if !ok { + tt = time.Duration(thinkTime.Time*1000) * time.Millisecond + break + } + res := builtin.GetRandomNumber(int(thinkTime.Time*m["min_percentage"]*1000), int(thinkTime.Time*m["max_percentage"]*1000)) + tt = time.Duration(res) * time.Millisecond + case thinkTimeMultiply: + value, ok := ttc.Setting.(float64) // e.g. 0.5 + if !ok || value <= 0 { + value = thinkTimeDefaultMultiply + } + tt = time.Duration(thinkTime.Time*value*1000) * time.Millisecond + case thinkTimeIgnore: + // nothing to do + } + // no more than limit + if ttc.Limit > 0 { + limit := time.Duration(ttc.Limit*1000) * time.Millisecond + if limit < tt { + tt = limit + } + } + time.Sleep(tt) + return stepResult, nil +} + +func (r *caseRunner) runStepTransaction(transaction *Transaction) (stepResult *stepData, err error) { + log.Info(). + Str("name", transaction.Name). + Str("type", string(transaction.Type)). + Msg("transaction") + + stepResult = &stepData{ + Name: transaction.Name, + StepType: stepTypeTransaction, + Success: true, + Elapsed: 0, + ContentSize: 0, // TODO: record transaction total response length + } + + // create transaction if not exists + if _, ok := r.transactions[transaction.Name]; !ok { + r.transactions[transaction.Name] = make(map[transactionType]time.Time) + } + + // record transaction start time, override if already exists + if transaction.Type == transactionStart { + r.transactions[transaction.Name][transactionStart] = time.Now() + } + // record transaction end time, override if already exists + if transaction.Type == transactionEnd { + r.transactions[transaction.Name][transactionEnd] = time.Now() + + // if transaction start time not exists, use testcase start time instead + if _, ok := r.transactions[transaction.Name][transactionStart]; !ok { + r.transactions[transaction.Name][transactionStart] = r.startTime + } + + // calculate transaction duration + duration := r.transactions[transaction.Name][transactionEnd].Sub( + r.transactions[transaction.Name][transactionStart]) + stepResult.Elapsed = duration.Milliseconds() + log.Info().Str("name", transaction.Name).Dur("elapsed", duration).Msg("transaction") + } + + return stepResult, nil +} + +func (r *caseRunner) runStepRendezvous(rendezvous *Rendezvous) (stepResult *stepData, err error) { + log.Info(). + Str("name", rendezvous.Name). + Float32("percent", rendezvous.Percent). + Int64("number", rendezvous.Number). + Int64("timeout", rendezvous.Timeout). + Msg("rendezvous") + stepResult = &stepData{ + Name: rendezvous.Name, + StepType: stepTypeRendezvous, + Success: true, + } + + // pass current rendezvous if already released, activate rendezvous sequentially after spawn done + if rendezvous.isReleased() || !r.isPreRendezvousAllReleased(rendezvous) || !rendezvous.isSpawnDone() { + return stepResult, nil + } + + // activate the rendezvous only once during each cycle + rendezvous.once.Do(func() { + close(rendezvous.activateChan) + }) + + // check current cnt using double check lock before updating to avoid negative WaitGroup counter + if atomic.LoadInt64(&rendezvous.cnt) < rendezvous.Number { + rendezvous.lock.Lock() + if atomic.LoadInt64(&rendezvous.cnt) < rendezvous.Number { + atomic.AddInt64(&rendezvous.cnt, 1) + rendezvous.wg.Done() + rendezvous.timerResetChan <- struct{}{} + } + rendezvous.lock.Unlock() + } + + // block until current rendezvous released + <-rendezvous.releaseChan + return stepResult, nil +} + +func (r *caseRunner) isPreRendezvousAllReleased(rendezvous *Rendezvous) bool { + tCase, _ := r.ToTCase() + for _, step := range tCase.TestSteps { + preRendezvous := step.Rendezvous + if preRendezvous == nil { + continue + } + // meet current rendezvous, all previous rendezvous released, return true + if preRendezvous == rendezvous { + return true + } + if !preRendezvous.isReleased() { + return false + } + } + return true +} + +func (r *Rendezvous) reset() { + r.cnt = 0 + r.releasedFlag = 0 + r.wg.Add(int(r.Number)) + // timerResetChan channel will not be closed, thus init only once + if r.timerResetChan == nil { + r.timerResetChan = make(chan struct{}) + } + r.activateChan = make(chan struct{}) + r.releaseChan = make(chan struct{}) + r.once = new(sync.Once) +} + +func (r *Rendezvous) isSpawnDone() bool { + return atomic.LoadUint32(&r.spawnDoneFlag) == 1 +} + +func (r *Rendezvous) setSpawnDone() { + atomic.StoreUint32(&r.spawnDoneFlag, 1) +} + +func (r *Rendezvous) isReleased() bool { + return atomic.LoadUint32(&r.releasedFlag) == 1 +} + +func (r *Rendezvous) setReleased() { + atomic.StoreUint32(&r.releasedFlag, 1) +} + +func initRendezvous(testcase *TestCase, total int64) []*Rendezvous { + tCase, _ := testcase.ToTCase() + var rendezvousList []*Rendezvous + for _, step := range tCase.TestSteps { + if step.Rendezvous == nil { + continue + } + rendezvous := step.Rendezvous + + // either number or percent should be correctly put, otherwise set to default (total) + if rendezvous.Number == 0 && rendezvous.Percent > 0 && rendezvous.Percent <= defaultRendezvousPercent { + rendezvous.Number = int64(rendezvous.Percent * float32(total)) + } else if rendezvous.Number > 0 && rendezvous.Number <= total && rendezvous.Percent == 0 { + rendezvous.Percent = float32(rendezvous.Number) / float32(total) + } else { + log.Warn(). + Str("name", rendezvous.Name). + Int64("default number", total). + Float32("default percent", defaultRendezvousPercent). + Msg("rendezvous parameter not defined or error, set to default value") + rendezvous.Number = total + rendezvous.Percent = defaultRendezvousPercent + } + + if rendezvous.Timeout <= 0 { + rendezvous.Timeout = defaultRendezvousTimeout + } + + rendezvous.reset() + rendezvousList = append(rendezvousList, rendezvous) + } + return rendezvousList +} + +func waitRendezvous(rendezvousList []*Rendezvous) { + if rendezvousList != nil { + lastRendezvous := rendezvousList[len(rendezvousList)-1] + for _, rendezvous := range rendezvousList { + go waitSingleRendezvous(rendezvous, rendezvousList, lastRendezvous) + } + } +} + +func waitSingleRendezvous(rendezvous *Rendezvous, rendezvousList []*Rendezvous, lastRendezvous *Rendezvous) { + for { + // cycle start: block current checking until current rendezvous activated + <-rendezvous.activateChan + stop := make(chan struct{}) + timeout := time.Duration(rendezvous.Timeout) * time.Millisecond + timer := time.NewTimer(timeout) + go func() { + defer close(stop) + rendezvous.wg.Wait() + }() + for !rendezvous.isReleased() { + select { + case <-rendezvous.timerResetChan: + timer.Reset(timeout) + case <-stop: + rendezvous.setReleased() + close(rendezvous.releaseChan) + log.Info(). + Str("name", rendezvous.Name). + Float32("percent", rendezvous.Percent). + Int64("number", rendezvous.Number). + Int64("timeout(ms)", rendezvous.Timeout). + Int64("cnt", rendezvous.cnt). + Str("reason", "rendezvous release condition satisfied"). + Msg("rendezvous released") + case <-timer.C: + rendezvous.setReleased() + close(rendezvous.releaseChan) + log.Info(). + Str("name", rendezvous.Name). + Float32("percent", rendezvous.Percent). + Int64("number", rendezvous.Number). + Int64("timeout(ms)", rendezvous.Timeout). + Int64("cnt", rendezvous.cnt). + Str("reason", "time's up"). + Msg("rendezvous released") + } + } + // cycle end: reset all previous rendezvous after last rendezvous released + // otherwise, block current checker until the last rendezvous end + if rendezvous == lastRendezvous { + for _, r := range rendezvousList { + r.reset() + } + } else { + <-lastRendezvous.releaseChan + } + } +} + +func (r *caseRunner) runStepRequest(step *TStep) (stepResult *stepData, err error) { + stepResult = &stepData{ + Name: step.Name, + StepType: stepTypeRequest, + Success: false, + ContentSize: 0, + } + sessionData := newSessionData() + + // convert request struct to map + jsonRequest, _ := json.Marshal(&step.Request) + var requestMap map[string]interface{} + _ = json.Unmarshal(jsonRequest, &requestMap) + + rawUrl := step.Request.URL + method := step.Request.Method + req := &http.Request{ + Method: method, + Header: make(http.Header), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + } + + // prepare request headers + if len(step.Request.Headers) > 0 { + headers, err := r.parser.parseHeaders(step.Request.Headers, step.Variables) + if err != nil { + return stepResult, errors.Wrap(err, "parse headers failed") + } + for key, value := range headers { + // omit pseudo header names for HTTP/1, e.g. :authority, :method, :path, :scheme + if strings.HasPrefix(key, ":") { + continue + } + req.Header.Add(key, value) + + // prepare content length + if strings.EqualFold(key, "Content-Length") && value != "" { + if l, err := strconv.ParseInt(value, 10, 64); err == nil { + req.ContentLength = l + } + } + } + } + + // prepare request params + var queryParams url.Values + if len(step.Request.Params) > 0 { + params, err := r.parser.parseData(step.Request.Params, step.Variables) + if err != nil { + return stepResult, errors.Wrap(err, "parse request params failed") + } + parsedParams := params.(map[string]interface{}) + requestMap["params"] = parsedParams + if len(parsedParams) > 0 { + queryParams = make(url.Values) + for k, v := range parsedParams { + queryParams.Add(k, fmt.Sprint(v)) + } + } + } + if queryParams != nil { + // append params to url + paramStr := queryParams.Encode() + if strings.IndexByte(rawUrl, '?') == -1 { + rawUrl = rawUrl + "?" + paramStr + } else { + rawUrl = rawUrl + "&" + paramStr + } + } + + // prepare request cookies + for cookieName, cookieValue := range step.Request.Cookies { + value, err := r.parser.parseData(cookieValue, step.Variables) + if err != nil { + return stepResult, errors.Wrap(err, "parse cookie value failed") + } + req.AddCookie(&http.Cookie{ + Name: cookieName, + Value: fmt.Sprintf("%v", value), + }) + } + + // prepare request body + if step.Request.Body != nil { + data, err := r.parser.parseData(step.Request.Body, step.Variables) + if err != nil { + return stepResult, err + } + // check request body format if Content-Type specified as application/json + if strings.HasPrefix(req.Header.Get("Content-Type"), "application/json") { + switch data.(type) { + case bool, float64, string, map[string]interface{}, []interface{}, nil: + break + default: + return stepResult, errors.Errorf("request body type inconsistent with Content-Type: %v", req.Header.Get("Content-Type")) + } + } + requestMap["body"] = data + var dataBytes []byte + switch vv := data.(type) { + case map[string]interface{}: + contentType := req.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + // post form data + formData := make(url.Values) + for k, v := range vv { + formData.Add(k, fmt.Sprint(v)) + } + dataBytes = []byte(formData.Encode()) + } else { + // post json + dataBytes, err = json.Marshal(vv) + if err != nil { + return stepResult, err + } + if contentType == "" { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + } + case []interface{}: + contentType := req.Header.Get("Content-Type") + // post json + dataBytes, err = json.Marshal(vv) + if err != nil { + return stepResult, err + } + if contentType == "" { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + case string: + dataBytes = []byte(vv) + case []byte: + dataBytes = vv + case bytes.Buffer: + dataBytes = vv.Bytes() + default: // unexpected body type + return stepResult, errors.New("unexpected request body type") + } + setBodyBytes(req, dataBytes) + } + // update header + headers := make(map[string]string) + for key, value := range req.Header { + headers[key] = value[0] + } + requestMap["headers"] = headers + + // prepare url + u, err := url.Parse(rawUrl) + if err != nil { + return stepResult, errors.Wrap(err, "parse url failed") + } + req.URL = u + req.Host = u.Host + + // add request object to step variables, could be used in setup hooks + step.Variables["hrp_step_name"] = step.Name + step.Variables["hrp_step_request"] = requestMap + + // deal with setup hooks + for _, setupHook := range step.SetupHooks { + _, err = r.parser.parseData(setupHook, step.Variables) + if err != nil { + return stepResult, errors.Wrap(err, "run setup hooks failed") + } + } + + // log & print request + if err := r.printRequest(req); err != nil { + return stepResult, err + } + + // do request action + start := time.Now() + resp, err := r.hrpRunner.client.Do(req) + stepResult.Elapsed = time.Since(start).Milliseconds() + if err != nil { + return stepResult, errors.Wrap(err, "do request failed") + } + defer resp.Body.Close() + + // decode response body in br/gzip/deflate formats + err = decodeResponseBody(resp) + if err != nil { + return stepResult, errors.Wrap(err, "decode response body failed") + } + + // log & print response + if err := r.printResponse(resp); err != nil { + return stepResult, err + } + + // new response object + respObj, err := newResponseObject(r.hrpRunner.t, r.parser, resp) + if err != nil { + err = errors.Wrap(err, "init ResponseObject error") + return + } + + // add response object to step variables, could be used in teardown hooks + step.Variables["hrp_step_response"] = respObj.respObjMeta + + // deal with teardown hooks + for _, teardownHook := range step.TeardownHooks { + _, err = r.parser.parseData(teardownHook, step.Variables) + if err != nil { + return stepResult, errors.Wrap(err, "run teardown hooks failed") + } + } + + sessionData.ReqResps.Request = requestMap + sessionData.ReqResps.Response = builtin.FormatResponse(respObj.respObjMeta) + + // extract variables from response + extractors := step.Extract + extractMapping := respObj.Extract(extractors) + stepResult.ExportVars = extractMapping + + // override step variables with extracted variables + stepVariables := mergeVariables(step.Variables, extractMapping) + + // validate response + err = respObj.Validate(step.Validators, stepVariables) + sessionData.Validators = respObj.validationResults + if err == nil { + sessionData.Success = true + stepResult.Success = true + } + stepResult.ContentSize = resp.ContentLength + stepResult.Data = sessionData + + return stepResult, err +} + +func (r *caseRunner) printRequest(req *http.Request) error { + if !r.hrpRunner.requestsLogOn { + return nil + } + reqContentType := req.Header.Get("Content-Type") + printBody := shouldPrintBody(reqContentType) + reqDump, err := httputil.DumpRequest(req, printBody) + if err != nil { + return errors.Wrap(err, "dump request failed") + } + fmt.Println("-------------------- request --------------------") + reqContent := string(reqDump) + if req.Body != nil && !printBody { + reqContent += fmt.Sprintf("(request body omitted for Content-Type: %v)", reqContentType) + } + fmt.Println(reqContent) + return nil +} + +func (r *caseRunner) printResponse(resp *http.Response) error { + if !r.hrpRunner.requestsLogOn { + return nil + } + fmt.Println("==================== response ===================") + respContentType := resp.Header.Get("Content-Type") + printBody := shouldPrintBody(respContentType) + respDump, err := httputil.DumpResponse(resp, printBody) + if err != nil { + return errors.Wrap(err, "dump response failed") + } + respContent := string(respDump) + if !printBody { + respContent += fmt.Sprintf("(response body omitted for Content-Type: %v)", respContentType) + } + fmt.Println(respContent) + fmt.Println("--------------------------------------------------") + return nil +} + +// shouldPrintBody return true if the Content-Type is printable +// including text/*, application/json, application/xml, application/www-form-urlencoded +func shouldPrintBody(contentType string) bool { + if strings.HasPrefix(contentType, "text/") { + return true + } + if strings.HasPrefix(contentType, "application/json") { + return true + } + if strings.HasPrefix(contentType, "application/xml") { + return true + } + if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + return true + } + return false +} + +func decodeResponseBody(resp *http.Response) (err error) { + switch resp.Header.Get("Content-Encoding") { + case "br": + resp.Body = io.NopCloser(brotli.NewReader(resp.Body)) + case "gzip": + resp.Body, err = gzip.NewReader(resp.Body) + if err != nil { + return err + } + resp.ContentLength = -1 // set to unknown to avoid Content-Length mismatched + case "deflate": + resp.Body, err = zlib.NewReader(resp.Body) + if err != nil { + return err + } + resp.ContentLength = -1 // set to unknown to avoid Content-Length mismatched + } + return nil +} + +func (r *caseRunner) runStepTestCase(step *TStep) (stepResult *stepData, err error) { + stepResult = &stepData{ + Name: step.Name, + StepType: stepTypeTestCase, + Success: false, + } + testcase := step.TestCaseContent + + // copy testcase to avoid data racing + copiedTestCase := &TestCase{} + if err = copier.Copy(copiedTestCase, testcase); err != nil { + log.Error().Err(err).Msg("copy testcase failed") + return stepResult, err + } + // override testcase config + extendWithTestCase(step, copiedTestCase) + + start := time.Now() + caseRunnerObj := r.hrpRunner.newCaseRunner(copiedTestCase) + err = caseRunnerObj.run() + stepResult.Elapsed = time.Since(start).Milliseconds() + if err != nil { + return stepResult, err + } + stepResult.Data = caseRunnerObj.getSummary() + // export testcase export variables + stepResult.ExportVars = caseRunnerObj.summary.InOut.ExportVars + stepResult.Success = true + return stepResult, nil +} + +func (r *caseRunner) parseConfig(cfg *TConfig) error { + // parse config variables + parsedVariables, err := r.parser.parseVariables(cfg.Variables) + if err != nil { + log.Error().Interface("variables", cfg.Variables).Err(err).Msg("parse config variables failed") + return err + } + cfg.Variables = parsedVariables + + // parse config name + parsedName, err := r.parser.parseString(cfg.Name, cfg.Variables) + if err != nil { + return err + } + cfg.Name = convertString(parsedName) + + // parse config base url + parsedBaseURL, err := r.parser.parseString(cfg.BaseURL, cfg.Variables) + if err != nil { + return err + } + cfg.BaseURL = convertString(parsedBaseURL) + + // ensure correction of think time config + cfg.ThinkTime.checkThinkTime() + + return nil +} + +func newSummary() *testCaseSummary { + return &testCaseSummary{ + Success: true, + Stat: &testStepStat{}, + Time: &testCaseTime{}, + InOut: &testCaseInOut{}, + } +} + +func (r *caseRunner) getSummary() *testCaseSummary { + caseSummary := r.summary + caseSummary.Time.StartAt = r.startTime + caseSummary.Time.Duration = time.Since(r.startTime).Seconds() + exportVars := make(map[string]interface{}) + for _, value := range r.Config.Export { + exportVars[value] = r.sessionVariables[value] + } + caseSummary.InOut.ExportVars = exportVars + caseSummary.InOut.ConfigVars = r.Config.Variables + return caseSummary +} + +func setBodyBytes(req *http.Request, data []byte) { + req.Body = io.NopCloser(bytes.NewReader(data)) + req.ContentLength = int64(len(data)) +} + +//go:embed internal/report/template.html +var reportTemplate string + +func (s *Summary) genHTMLReport() error { + dir, _ := filepath.Split(reportPath) + err := builtin.EnsureFolderExists(dir) + if err != nil { + return err + } + file, err := os.OpenFile(fmt.Sprintf(reportPath, s.Time.StartAt.Unix()), os.O_WRONLY|os.O_CREATE, 0666) + defer file.Close() + if err != nil { + log.Error().Err(err).Msg("open file failed") + return err + } + writer := bufio.NewWriter(file) + tmpl := template.Must(template.New("report").Parse(reportTemplate)) + err = tmpl.Execute(writer, s) + if err != nil { + log.Error().Err(err).Msg("execute applies a parsed template to the specified data object failed") + return err + } + err = writer.Flush() + return err +} diff --git a/hrp/runner_test.go b/hrp/runner_test.go new file mode 100644 index 00000000..d6945425 --- /dev/null +++ b/hrp/runner_test.go @@ -0,0 +1,231 @@ +package hrp + +import ( + "math" + "os" + "os/exec" + "testing" + "time" + + "github.com/rs/zerolog/log" +) + +func buildHashicorpPlugin() { + log.Info().Msg("[init] build hashicorp go plugin") + cmd := exec.Command("go", "build", + "-o", "../examples/hrp/debugtalk.bin", + "../examples/hrp/plugin/hashicorp.go", "../examples/hrp/plugin/debugtalk.go") + if err := cmd.Run(); err != nil { + panic(err) + } +} + +func removeHashicorpPlugin() { + log.Info().Msg("[teardown] remove hashicorp plugin") + os.Remove("../examples/hrp/debugtalk.bin") +} + +func TestHttpRunnerWithGoPlugin(t *testing.T) { + buildHashicorpPlugin() + defer removeHashicorpPlugin() + + assertRunTestCases(t) +} + +func TestHttpRunnerWithPythonPlugin(t *testing.T) { + assertRunTestCases(t) +} + +func assertRunTestCases(t *testing.T) { + testcase1 := &TestCase{ + Config: NewConfig("TestCase1"). + SetBaseURL("http://httpbin.org"), + TestSteps: []IStep{ + NewStep("headers"). + GET("/headers"). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("headers.\"Content-Type\"", "application/json", "check http response Content-Type"), + NewStep("user-agent"). + GET("/user-agent"). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("headers.\"Content-Type\"", "application/json", "check http response Content-Type"), + NewStep("TestCase3").CallRefCase( + &TestCase{ + Config: NewConfig("TestCase3").SetBaseURL("http://httpbin.org"), + TestSteps: []IStep{ + NewStep("ip"). + GET("/ip"). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("headers.\"Content-Type\"", "application/json", "check http response Content-Type"), + }, + }, + ), + NewStep("TestCase4").CallRefCase(&demoRefAPIYAMLPath), + NewStep("TestCase5").CallRefCase(&demoTestCaseJSONPath), + }, + } + testcase2 := &TestCase{ + Config: NewConfig("TestCase2").SetWeight(3), + } + testcase3 := &TestCase{ + Config: NewConfig("TestCase1"). + SetBaseURL("https://postman-echo.com"), + TestSteps: []IStep{ + NewStep("TestCase5").CallRefAPI(&demoAPIYAMLPath), + }, + } + testcase4 := &demoRefTestCaseJSONPath + + r := NewRunner(t) + r.SetPluginLogOn() + err := r.Run(testcase1, testcase2, testcase3, testcase4) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } +} + +func TestInitRendezvous(t *testing.T) { + rendezvousBonudaryTestcase := &TestCase{ + Config: NewConfig("run request with functions"). + SetBaseURL("https://postman-echo.com"). + WithVariables(map[string]interface{}{ + "n": 5, + "a": 12.3, + "b": 3.45, + }), + TestSteps: []IStep{ + NewStep("test negative number"). + Rendezvous("test negative number"). + WithUserNumber(-1), + NewStep("test overflow number"). + Rendezvous("test overflow number"). + WithUserNumber(1000000), + NewStep("test negative percent"). + Rendezvous("test very low percent"). + WithUserPercent(-0.5), + NewStep("test very low percent"). + Rendezvous("test very low percent"). + WithUserPercent(0.00001), + NewStep("test overflow percent"). + Rendezvous("test overflow percent"). + WithUserPercent(1.5), + NewStep("test conflict params"). + Rendezvous("test conflict params"). + WithUserNumber(1). + WithUserPercent(0.123), + NewStep("test negative timeout"). + Rendezvous("test negative timeout"). + WithTimeout(-1000), + }, + } + + type rendezvousParam struct { + number int64 + percent float32 + timeout int64 + } + expectedRendezvousParams := []rendezvousParam{ + {number: 100, percent: 1, timeout: 5000}, + {number: 100, percent: 1, timeout: 5000}, + {number: 100, percent: 1, timeout: 5000}, + {number: 0, percent: 0.00001, timeout: 5000}, + {number: 100, percent: 1, timeout: 5000}, + {number: 100, percent: 1, timeout: 5000}, + {number: 100, percent: 1, timeout: 5000}, + } + + rendezvousList := initRendezvous(rendezvousBonudaryTestcase, 100) + + for i, r := range rendezvousList { + if r.Number != expectedRendezvousParams[i].number { + t.Fatalf("run rendezvous %v error: expected number: %v, real number: %v", r.Name, expectedRendezvousParams[i].number, r.Number) + } + if math.Abs(float64(r.Percent-expectedRendezvousParams[i].percent)) > 0.001 { + t.Fatalf("run rendezvous %v error: expected percent: %v, real percent: %v", r.Name, expectedRendezvousParams[i].percent, r.Percent) + } + if r.Timeout != expectedRendezvousParams[i].timeout { + t.Fatalf("run rendezvous %v error: expected timeout: %v, real timeout: %v", r.Name, expectedRendezvousParams[i].timeout, r.Timeout) + } + } +} + +func TestThinkTime(t *testing.T) { + buildHashicorpPlugin() + defer removeHashicorpPlugin() + + testcases := []*TestCase{ + { + Config: NewConfig("TestCase1"), + TestSteps: []IStep{ + NewStep("thinkTime").SetThinkTime(2), + }, + }, + { + Config: NewConfig("TestCase2"). + SetThinkTime(thinkTimeIgnore, nil, 0), + TestSteps: []IStep{ + NewStep("thinkTime").SetThinkTime(0.5), + }, + }, + { + Config: NewConfig("TestCase3"). + SetThinkTime(thinkTimeRandomPercentage, nil, 0), + TestSteps: []IStep{ + NewStep("thinkTime").SetThinkTime(1), + }, + }, + { + Config: NewConfig("TestCase4"). + SetThinkTime(thinkTimeRandomPercentage, map[string]interface{}{"min_percentage": 2, "max_percentage": 3}, 2.5), + TestSteps: []IStep{ + NewStep("thinkTime").SetThinkTime(1), + }, + }, + { + Config: NewConfig("TestCase5"), + TestSteps: []IStep{ + NewStep("thinkTime").CallRefCase(&demoThinkTimeJsonPath), // think time: 3s, random pct: {"min_percentage":1, "max_percentage":1.5}, limit: 4s + }, + }, + } + expectedMinValue := []float64{2, 0, 0.5, 2, 3} + expectedMaxValue := []float64{2.5, 0.5, 2, 3, 10} + for idx, testcase := range testcases { + r := NewRunner(t) + startTime := time.Now() + err := r.Run(testcase) + if err != nil { + t.Fatalf("run testcase error: %v", err) + } + duration := time.Since(startTime) + minValue := time.Duration(expectedMinValue[idx]*1000) * time.Millisecond + maxValue := time.Duration(expectedMaxValue[idx]*1000) * time.Millisecond + if duration < minValue || duration > maxValue { + t.Fatalf("failed to test think time, expect value: [%v, %v], actual value: %v", minValue, maxValue, duration) + } + } +} + +func TestGenHTMLReport(t *testing.T) { + summary := newOutSummary() + caseSummary1 := newSummary() + caseSummary2 := newSummary() + stepResult1 := &stepData{} + stepResult2 := &stepData{ + Name: "Test", + StepType: stepTypeRequest, + Success: false, + ContentSize: 0, + Attachment: "err", + } + caseSummary1.Records = []*stepData{stepResult1, stepResult2, nil} + summary.appendCaseSummary(caseSummary1) + summary.appendCaseSummary(caseSummary2) + err := summary.genHTMLReport() + if err != nil { + t.Error(err) + } +} diff --git a/hrp/step.go b/hrp/step.go new file mode 100644 index 00000000..829e9aad --- /dev/null +++ b/hrp/step.go @@ -0,0 +1,457 @@ +package hrp + +import "fmt" + +// NewConfig returns a new constructed testcase config with specified testcase name. +func NewConfig(name string) *TConfig { + return &TConfig{ + Name: name, + Variables: make(map[string]interface{}), + } +} + +// WithVariables sets variables for current testcase. +func (c *TConfig) WithVariables(variables map[string]interface{}) *TConfig { + c.Variables = variables + return c +} + +// SetBaseURL sets base URL for current testcase. +func (c *TConfig) SetBaseURL(baseURL string) *TConfig { + c.BaseURL = baseURL + return c +} + +// SetHeaders sets global headers for current testcase. +func (c *TConfig) SetHeaders(headers map[string]string) *TConfig { + c.Headers = headers + return c +} + +// SetVerifySSL sets whether to verify SSL for current testcase. +func (c *TConfig) SetVerifySSL(verify bool) *TConfig { + c.Verify = verify + return c +} + +// WithParameters sets parameters for current testcase. +func (c *TConfig) WithParameters(parameters map[string]interface{}) *TConfig { + c.Parameters = parameters + return c +} + +// SetThinkTime sets think time config for current testcase. +func (c *TConfig) SetThinkTime(strategy string, cfg interface{}, limit float64) *TConfig { + c.ThinkTime = &ThinkTimeConfig{strategy, cfg, limit} + return c +} + +// ExportVars specifies variable names to export for current testcase. +func (c *TConfig) ExportVars(vars ...string) *TConfig { + c.Export = vars + return c +} + +// SetWeight sets weight for current testcase, which is used in load testing. +func (c *TConfig) SetWeight(weight int) *TConfig { + c.Weight = weight + return c +} + +// NewStep returns a new constructed teststep with specified step name. +func NewStep(name string) *StepRequest { + return &StepRequest{ + step: &TStep{ + Name: name, + Variables: make(map[string]interface{}), + }, + } +} + +type StepRequest struct { + step *TStep +} + +// WithVariables sets variables for current teststep. +func (s *StepRequest) WithVariables(variables map[string]interface{}) *StepRequest { + s.step.Variables = variables + return s +} + +// SetupHook adds a setup hook for current teststep. +func (s *StepRequest) SetupHook(hook string) *StepRequest { + s.step.SetupHooks = append(s.step.SetupHooks, hook) + return s +} + +// GET makes a HTTP GET request. +func (s *StepRequest) GET(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpGET, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// HEAD makes a HTTP HEAD request. +func (s *StepRequest) HEAD(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpHEAD, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// POST makes a HTTP POST request. +func (s *StepRequest) POST(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpPOST, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// PUT makes a HTTP PUT request. +func (s *StepRequest) PUT(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpPUT, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// DELETE makes a HTTP DELETE request. +func (s *StepRequest) DELETE(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpDELETE, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// OPTIONS makes a HTTP OPTIONS request. +func (s *StepRequest) OPTIONS(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpOPTIONS, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// PATCH makes a HTTP PATCH request. +func (s *StepRequest) PATCH(url string) *StepRequestWithOptionalArgs { + s.step.Request = &Request{ + Method: httpPATCH, + URL: url, + } + return &StepRequestWithOptionalArgs{ + step: s.step, + } +} + +// CallRefCase calls a referenced testcase. +func (s *StepRequest) CallRefCase(tc ITestCase) *StepTestCaseWithOptionalArgs { + s.step.TestCaseContent, _ = tc.ToTestCase() + return &StepTestCaseWithOptionalArgs{ + step: s.step, + } +} + +// CallRefAPI calls a referenced api. +func (s *StepRequest) CallRefAPI(api IAPI) *StepAPIWithOptionalArgs { + s.step.APIContent, _ = api.ToAPI() + return &StepAPIWithOptionalArgs{ + step: s.step, + } +} + +// StartTransaction starts a transaction. +func (s *StepRequest) StartTransaction(name string) *StepTransaction { + s.step.Transaction = &Transaction{ + Name: name, + Type: transactionStart, + } + return &StepTransaction{ + step: s.step, + } +} + +// EndTransaction ends a transaction. +func (s *StepRequest) EndTransaction(name string) *StepTransaction { + s.step.Transaction = &Transaction{ + Name: name, + Type: transactionEnd, + } + return &StepTransaction{ + step: s.step, + } +} + +// SetThinkTime sets think time. +func (s *StepRequest) SetThinkTime(time float64) *StepThinkTime { + s.step.ThinkTime = &ThinkTime{ + Time: time, + } + return &StepThinkTime{ + step: s.step, + } +} + +// StepRequestWithOptionalArgs implements IStep interface. +type StepRequestWithOptionalArgs struct { + step *TStep +} + +// SetVerify sets whether to verify SSL for current HTTP request. +func (s *StepRequestWithOptionalArgs) SetVerify(verify bool) *StepRequestWithOptionalArgs { + s.step.Request.Verify = verify + return s +} + +// SetTimeout sets timeout for current HTTP request. +func (s *StepRequestWithOptionalArgs) SetTimeout(timeout float32) *StepRequestWithOptionalArgs { + s.step.Request.Timeout = timeout + return s +} + +// SetProxies sets proxies for current HTTP request. +func (s *StepRequestWithOptionalArgs) SetProxies(proxies map[string]string) *StepRequestWithOptionalArgs { + // TODO + return s +} + +// SetAllowRedirects sets whether to allow redirects for current HTTP request. +func (s *StepRequestWithOptionalArgs) SetAllowRedirects(allowRedirects bool) *StepRequestWithOptionalArgs { + s.step.Request.AllowRedirects = allowRedirects + return s +} + +// SetAuth sets auth for current HTTP request. +func (s *StepRequestWithOptionalArgs) SetAuth(auth map[string]string) *StepRequestWithOptionalArgs { + // TODO + return s +} + +// WithParams sets HTTP request params for current step. +func (s *StepRequestWithOptionalArgs) WithParams(params map[string]interface{}) *StepRequestWithOptionalArgs { + s.step.Request.Params = params + return s +} + +// WithHeaders sets HTTP request headers for current step. +func (s *StepRequestWithOptionalArgs) WithHeaders(headers map[string]string) *StepRequestWithOptionalArgs { + s.step.Request.Headers = headers + return s +} + +// WithCookies sets HTTP request cookies for current step. +func (s *StepRequestWithOptionalArgs) WithCookies(cookies map[string]string) *StepRequestWithOptionalArgs { + s.step.Request.Cookies = cookies + return s +} + +// WithBody sets HTTP request body for current step. +func (s *StepRequestWithOptionalArgs) WithBody(body interface{}) *StepRequestWithOptionalArgs { + s.step.Request.Body = body + return s +} + +// TeardownHook adds a teardown hook for current teststep. +func (s *StepRequestWithOptionalArgs) TeardownHook(hook string) *StepRequestWithOptionalArgs { + s.step.TeardownHooks = append(s.step.TeardownHooks, hook) + return s +} + +// Validate switches to step validation. +func (s *StepRequestWithOptionalArgs) Validate() *StepRequestValidation { + return &StepRequestValidation{ + step: s.step, + } +} + +// Extract switches to step extraction. +func (s *StepRequestWithOptionalArgs) Extract() *StepRequestExtraction { + s.step.Extract = make(map[string]string) + return &StepRequestExtraction{ + step: s.step, + } +} + +func (s *StepRequestWithOptionalArgs) Name() string { + if s.step.Name != "" { + return s.step.Name + } + return fmt.Sprintf("%s %s", s.step.Request.Method, s.step.Request.URL) +} + +func (s *StepRequestWithOptionalArgs) Type() string { + return fmt.Sprintf("request-%v", s.step.Request.Method) +} + +func (s *StepRequestWithOptionalArgs) ToStruct() *TStep { + return s.step +} + +// StepAPIWithOptionalArgs implements IStep interface. +type StepAPIWithOptionalArgs struct { + step *TStep +} + +// TeardownHook adds a teardown hook for current teststep. +func (s *StepAPIWithOptionalArgs) TeardownHook(hook string) *StepAPIWithOptionalArgs { + s.step.TeardownHooks = append(s.step.TeardownHooks, hook) + return s +} + +// Export specifies variable names to export from referenced api for current step. +func (s *StepAPIWithOptionalArgs) Export(names ...string) *StepAPIWithOptionalArgs { + api, _ := s.step.APIContent.ToAPI() + s.step.Export = append(api.Export, names...) + return s +} + +func (s *StepAPIWithOptionalArgs) Name() string { + if s.step.Name != "" { + return s.step.Name + } + api, _ := s.step.APIContent.ToAPI() + return api.Name +} + +func (s *StepAPIWithOptionalArgs) Type() string { + return "api" +} + +func (s *StepAPIWithOptionalArgs) ToStruct() *TStep { + return s.step +} + +// StepTestCaseWithOptionalArgs implements IStep interface. +type StepTestCaseWithOptionalArgs struct { + step *TStep +} + +// TeardownHook adds a teardown hook for current teststep. +func (s *StepTestCaseWithOptionalArgs) TeardownHook(hook string) *StepTestCaseWithOptionalArgs { + s.step.TeardownHooks = append(s.step.TeardownHooks, hook) + return s +} + +// Export specifies variable names to export from referenced testcase for current step. +func (s *StepTestCaseWithOptionalArgs) Export(names ...string) *StepTestCaseWithOptionalArgs { + s.step.Export = append(s.step.Export, names...) + return s +} + +func (s *StepTestCaseWithOptionalArgs) Name() string { + if s.step.Name != "" { + return s.step.Name + } + ts, _ := s.step.TestCaseContent.ToTestCase() + return ts.Config.Name +} + +func (s *StepTestCaseWithOptionalArgs) Type() string { + return "testcase" +} + +func (s *StepTestCaseWithOptionalArgs) ToStruct() *TStep { + return s.step +} + +// StepThinkTime implements IStep interface. +type StepThinkTime struct { + step *TStep +} + +func (s *StepThinkTime) Name() string { + return s.step.Name +} + +func (s *StepThinkTime) Type() string { + return "thinktime" +} + +func (s *StepThinkTime) ToStruct() *TStep { + return s.step +} + +// StepTransaction implements IStep interface. +type StepTransaction struct { + step *TStep +} + +func (s *StepTransaction) Name() string { + if s.step.Name != "" { + return s.step.Name + } + return fmt.Sprintf("transaction %s %s", s.step.Transaction.Name, s.step.Transaction.Type) +} + +func (s *StepTransaction) Type() string { + return "transaction" +} + +func (s *StepTransaction) ToStruct() *TStep { + return s.step +} + +// StepRendezvous implements IStep interface. +type StepRendezvous struct { + step *TStep +} + +func (s *StepRendezvous) Name() string { + if s.step.Name != "" { + return s.step.Name + } + return s.step.Rendezvous.Name +} + +func (s *StepRendezvous) Type() string { + return "rendezvous" +} + +func (s *StepRendezvous) ToStruct() *TStep { + return s.step +} + +// Rendezvous creates a new rendezvous +func (s *StepRequest) Rendezvous(name string) *StepRendezvous { + s.step.Rendezvous = &Rendezvous{ + Name: name, + } + return &StepRendezvous{ + step: s.step, + } +} + +// WithUserNumber sets the user number needed to release the current rendezvous +func (s *StepRendezvous) WithUserNumber(number int64) *StepRendezvous { + s.step.Rendezvous.Number = number + return s +} + +// WithUserPercent sets the user percent needed to release the current rendezvous +func (s *StepRendezvous) WithUserPercent(percent float32) *StepRendezvous { + s.step.Rendezvous.Percent = percent + return s +} + +// WithTimeout sets the timeout of duration between each user arriving at the current rendezvous +func (s *StepRendezvous) WithTimeout(timeout int64) *StepRendezvous { + s.step.Rendezvous.Timeout = timeout + return s +} diff --git a/hrp/step_test.go b/hrp/step_test.go new file mode 100644 index 00000000..c84a07cb --- /dev/null +++ b/hrp/step_test.go @@ -0,0 +1,89 @@ +package hrp + +import ( + "testing" +) + +var ( + stepGET = NewStep("get with params"). + GET("/get"). + WithParams(map[string]interface{}{"foo1": "bar1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus"}). + WithCookies(map[string]string{"user": "debugtalk"}). + Validate(). + AssertEqual("status_code", 200, "check status code"). + AssertEqual("headers.\"Content-Type\"", "application/json; charset=utf-8", "check header Content-Type"). + AssertEqual("body.args.foo1", "bar1", "check param foo1"). + AssertEqual("body.args.foo2", "bar2", "check param foo2") + stepPOSTData = NewStep("post form data"). + POST("/post"). + WithParams(map[string]interface{}{"foo1": "bar1", "foo2": "bar2"}). + WithHeaders(map[string]string{"User-Agent": "HttpRunnerPlus", "Content-Type": "application/x-www-form-urlencoded"}). + WithBody("a=1&b=2"). + WithCookies(map[string]string{"user": "debugtalk"}). + Validate(). + AssertEqual("status_code", 200, "check status code") +) + +func TestRunRequestGetToStruct(t *testing.T) { + tStep := stepGET.step + if tStep.Request.Method != httpGET { + t.Fatalf("tStep.Request.Method != GET") + } + if tStep.Request.URL != "/get" { + t.Fatalf("tStep.Request.URL != '/get'") + } + if tStep.Request.Params["foo1"] != "bar1" || tStep.Request.Params["foo2"] != "bar2" { + t.Fatalf("tStep.Request.Params mismatch") + } + if tStep.Request.Headers["User-Agent"] != "HttpRunnerPlus" { + t.Fatalf("tStep.Request.Headers mismatch") + } + if tStep.Request.Cookies["user"] != "debugtalk" { + t.Fatalf("tStep.Request.Cookies mismatch") + } + validator, ok := tStep.Validators[0].(Validator) + if !ok || validator.Check != "status_code" || validator.Expect != 200 { + t.Fatalf("tStep.Validators mismatch") + } +} + +func TestRunRequestPostDataToStruct(t *testing.T) { + tStep := stepPOSTData.step + if tStep.Request.Method != httpPOST { + t.Fatalf("tStep.Request.Method != POST") + } + if tStep.Request.URL != "/post" { + t.Fatalf("tStep.Request.URL != '/post'") + } + if tStep.Request.Params["foo1"] != "bar1" || tStep.Request.Params["foo2"] != "bar2" { + t.Fatalf("tStep.Request.Params mismatch") + } + if tStep.Request.Headers["User-Agent"] != "HttpRunnerPlus" { + t.Fatalf("tStep.Request.Headers mismatch") + } + if tStep.Request.Cookies["user"] != "debugtalk" { + t.Fatalf("tStep.Request.Cookies mismatch") + } + if tStep.Request.Body != "a=1&b=2" { + t.Fatalf("tStep.Request.Data mismatch") + } + validator, ok := tStep.Validators[0].(Validator) + if !ok || validator.Check != "status_code" || validator.Expect != 200 { + t.Fatalf("tStep.Validators mismatch") + } +} + +func TestRunRequestRun(t *testing.T) { + testcase := &TestCase{ + Config: NewConfig("test").SetBaseURL("https://postman-echo.com"), + TestSteps: []IStep{stepGET, stepPOSTData}, + } + runner := NewRunner(t).SetRequestsLogOn().newCaseRunner(testcase) + if _, err := runner.runStep(0, testcase.Config); err != nil { + t.Fatalf("tStep.Run() error: %s", err) + } + if _, err := runner.runStep(1, testcase.Config); err != nil { + t.Fatalf("tStepPOSTData.Run() error: %s", err) + } +} diff --git a/hrp/validate.go b/hrp/validate.go new file mode 100644 index 00000000..51150563 --- /dev/null +++ b/hrp/validate.go @@ -0,0 +1,223 @@ +package hrp + +import ( + "fmt" +) + +// StepRequestValidation implements IStep interface. +type StepRequestValidation struct { + step *TStep +} + +func (s *StepRequestValidation) Name() string { + if s.step.Name != "" { + return s.step.Name + } + return fmt.Sprintf("%s %s", s.step.Request.Method, s.step.Request.URL) +} + +func (s *StepRequestValidation) Type() string { + return fmt.Sprintf("request-%v", s.step.Request.Method) +} + +func (s *StepRequestValidation) ToStruct() *TStep { + return s.step +} + +func (s *StepRequestValidation) AssertEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertGreater(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "greater_than", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLess(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "less_than", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertGreaterOrEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "greater_or_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLessOrEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "less_or_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertNotEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "not_equal", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertContains(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "contains", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertTypeMatch(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "type_match", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertRegexp(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "regex_match", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertStartsWith(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "startswith", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertEndsWith(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "endswith", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLengthEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "length_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertContainedBy(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "contained_by", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLengthLessThan(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "length_less_than", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertStringEqual(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "string_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLengthLessOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "length_less_or_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLengthGreaterThan(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "length_greater_than", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} + +func (s *StepRequestValidation) AssertLengthGreaterOrEquals(jmesPath string, expected interface{}, msg string) *StepRequestValidation { + v := Validator{ + Check: jmesPath, + Assert: "length_greater_or_equals", + Expect: expected, + Message: msg, + } + s.step.Validators = append(s.step.Validators, v) + return s +} diff --git a/httprunner/__init__.py b/httprunner/__init__.py index 74e8cf1d..6f10a6f5 100644 --- a/httprunner/__init__.py +++ b/httprunner/__init__.py @@ -1,8 +1,7 @@ -__version__ = "3.1.8-beta" +__version__ = "4.0.0-alpha" __description__ = "One-stop solution for HTTP(S) testing." # import firstly for monkey patch if needed -from httprunner.ext.locust import main_locusts from httprunner.parser import parse_parameters as Parameters from httprunner.runner import HttpRunner from httprunner.testcase import Config, Step, RunRequest, RunTestCase diff --git a/main.go b/main.go new file mode 100644 index 00000000..cf740e00 --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/httprunner/httprunner/hrp/cmd" +) + +func main() { + cmd.Execute() +} diff --git a/pyproject.toml b/pyproject.toml index a17c0b43..c52e6d89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "httprunner" -version = "3.1.8-beta" +version = "4.0.0-alpha" description = "One-stop solution for HTTP(S) testing." license = "Apache-2.0" readme = "docs/README.md" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 00000000..428b5b1b --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# build hrp cli binary for testing +# release will be triggered on github actions, see .github/workflows/release.yml + +# Usage: +# $ make build +# or +# $ bash cli/scripts/build.sh + +set -e +set -x + +# prepare path +mkdir -p "output" +bin_path="output/hrp" + +# build +go build -ldflags '-s -w' -o "$bin_path" main.go + +# check output and version +ls -lh "$bin_path" +chmod +x "$bin_path" +./"$bin_path" -v diff --git a/scripts/bump_version.sh b/scripts/bump_version.sh new file mode 100644 index 00000000..84e730f6 --- /dev/null +++ b/scripts/bump_version.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# build hrp cli binary for testing +# release will be triggered on github actions, see .github/workflows/release.yml + +# Usage: +# $ make bump version=v0.5.2 +# or +# $ bash cli/scripts/bump_version.sh v0.5.2 + +set -e + +version=$1 + +if [ -z "$version" ]; then + echo "version is required" + exit 1 +fi + +echo "bump hrp version to $version" +sed -i'.bak' "s/\".*\"/\"v$version\"/g" hrp/internal/version/init.go + +echo "bump install.sh version to $version" +sed -i'.bak' "s/LATEST_VERSION=\".*\"/LATEST_VERSION=\"v$version\"/g" scripts/install.sh + +echo "bump httprunner version to $version" +sed -i'.bak' "s/__version__ = \".*\"/__version__ = \"$version\"/g" httprunner/__init__.py + +echo "bump pyproject.toml version to $version" +sed -i'.bak' "s/^version = \".*\"/version = \"$version\"/g" pyproject.toml diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 00000000..1858cc7b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# install hrp with one shell command +# bash -c "$(curl -ksSL https://httprunner.oss-cn-beijing.aliyuncs.com/install.sh)" + +LATEST_VERSION="v4.0.0-alpha" + +set -e + +function echoError() { + echo -e "\033[31m✘ $1\033[0m" # red +} +export -f echoError + +function echoInfo() { + echo -e "\033[32m✔ $1\033[0m" # green +} +export -f echoInfo + +function echoWarn() { + echo -e "\033[33m! $1\033[0m" # yellow +} +export -f echoError + +function get_latest_version() { + # Release v0.4.0 · httprunner/hrp · GitHub + curl -sL https://github.com/httprunner/hrp/releases/latest | grep 'Release' | cut -d" " -f4 +} + +function get_os() { + os=$(uname -s) + echo "$os" | tr '[:upper:]' '[:lower:]' +} + +function get_arch() { + arch=$(uname -m) + if [ "$arch" == "x86_64" ]; then + arch="amd64" + fi + echo "$arch" +} + +function get_pkg_suffix() { + os=$1 + if [ "$os" == "windows" ]; then + echo ".zip" + else + echo ".tar.gz" + fi +} + +function extract_pkg() { + pkg=$1 + if [[ $pkg == *.zip ]]; then # windows + echo "$ unzip -o $pkg -d ." + unzip -o $pkg -d . + else + echo "$ tar -xzf $pkg" + tar -xzf "$pkg" + fi +} + +function main() { + echoInfo "Detect target hrp package..." + version=$LATEST_VERSION + os=$(get_os) + echo "Current OS: $os" + arch=$(get_arch) + echo "Current ARCH: $arch" + pkg_suffix=$(get_pkg_suffix $os) + pkg="hrp-$version-$os-$arch$pkg_suffix" + + # download from aliyun OSS + url="https://httprunner.oss-cn-beijing.aliyuncs.com/$pkg" + if ! curl --output /dev/null --silent --head --fail "$url"; then + # aliyun OSS url is invalid, try to download from github + version=$(get_latest_version) + pkg="hrp-$version-$os-$arch$pkg_suffix" + url="https://github.com/httprunner/hrp/releases/download/$version/$pkg" + fi + + echo "Latest version: $version" + echo "Download url: $url" + echo + + echoInfo "Created temp dir..." + echo "$ mktemp -d -t hrp.XXXX" + tmp_dir=$(mktemp -d -t hrp.XXXX) + echo "$tmp_dir" + cd "$tmp_dir" + echo + + echoInfo "Downloading..." + echo "$ curl -kL $url -o $pkg" + curl -kL $url -o "$pkg" + echo + + echoInfo "Extracting..." + extract_pkg "$pkg" + echo "$ ls -lh" + ls -lh + echo + + echoInfo "Installing..." + if hrp -v > /dev/null && [ $(command -v hrp) != "./hrp" ]; then + echoWarn "$(hrp -v) exists, remove first !!!" + echo "$ rm -rf $(command -v hrp)" + rm -rf "$(command -v hrp)" + fi + + echo "$ chmod +x hrp && mv hrp /usr/local/bin/" + chmod +x hrp + mv hrp /usr/local/bin/ + echo + + echoInfo "Check installation..." + echo "$ command -v hrp" + command -v hrp + echo "$ hrp -v" + hrp -v + echo "$ hrp -h" + hrp -h +} + +main