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+)
+
+[](https://pkg.go.dev/github.com/httprunner/hrp)
+[](https://github.com/httprunner/hrp/actions)
+[](https://codecov.io/gh/httprunner/hrp)
+[](https://goreportcard.com/report/github.com/httprunner/hrp)
+[](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
+
+
+
+### 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 核心用户群,请填写[用户调研问卷][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 00000000..98515853
Binary files /dev/null and b/hrp/docs/assets/flow.jpg differ
diff --git a/hrp/docs/assets/hogwarts.jpeg b/hrp/docs/assets/hogwarts.jpeg
new file mode 100644
index 00000000..78105f91
Binary files /dev/null and b/hrp/docs/assets/hogwarts.jpeg differ
diff --git a/hrp/docs/assets/qrcode.jpg b/hrp/docs/assets/qrcode.jpg
new file mode 100644
index 00000000..386a237d
Binary files /dev/null and b/hrp/docs/assets/qrcode.jpg differ
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 @@
+
\ 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 |
+
+
+ | PLATFORM |
+ HttpRunnerPlus {{ .Platform.HttprunnerVersion }} |
+ {{ .Platform.GoVersion }} |
+ {{ .Platform.Platform }} |
+
+
+ | STAT |
+ TESTCASES (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}}
+
+
+ | TOTAL: {{.Stat.Total}} |
+ SUCCESS: {{.Stat.Successes}} |
+ FAILED: 0 |
+ ERROR: {{.Stat.Failures}} |
+ SKIPPED: 0 |
+
+
+ | Status |
+ Name |
+ Response Time |
+ Detail |
+
+ {{- range $loop_index, $record := .Records }}
+ {{- with $record}}
+ {{- $status := "error"}}
+ {{- if .Success }} {{ $status = "success" }} {{ end }}
+
+ | {{$status}} |
+ {{.Name}} |
+ {{ .Elapsed }} ms |
+
+ log
+
+ {{ if .Attachment }}
+ traceback
+
+ {{- end }}
+ |
+
+ {{- end }}
+ {{- 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