Repository: Biometria-se/grizzly (language: python)

Issue:
add tests for step expressions
right now there's only tests for step implementations, but there is a gap behave and grizzly.

need to find a way to programatically call behave, that would parse and "execute" a feature file, and then being able to verify what the step implementation has been called with.


Discussion / hints:

Fix patch:
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 0f9c9cb7..20d85c1c 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -65,7 +65,8 @@
 		"ryanluker.vscode-coverage-gutters",
 		"stevejpurves.cucumber",
 		"silverbulleters.gherkin-autocomplete",
-		"matangover.mypy"
+		"matangover.mypy",
+        "redhat.vscode-yaml"
 	],
 	"mounts": [
 		"source=/etc/timezone,target=/etc/timezone,type=bind",
diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml
index 78f5cc13..5dc0c5cb 100644
--- a/.github/workflows/code-quality.yaml
+++ b/.github/workflows/code-quality.yaml
@@ -118,13 +118,54 @@ jobs:
 
     - name: pytest
       id: pytest
-      run: python -m pytest
+      run: python -m pytest --ignore=tests/e2e
 
     - name: coverage
       id: coverage
       run: python -m coverage report --omit=**/*messagequeue*,**/*mq.py --fail-under=95
 
+  test-e2e:
+    name: 'e2e tests'
+    needs: test-and-coverage
+    runs-on: ubuntu-latest
+    if: always()
+
+    steps:
+    - name: checkout
+      id: checkout
+      uses: actions/checkout@v2
+
+    - name: setup python (with cache)
+      if: ${{ !env.ACT }}
+      id: setup-python-with-cache
+      uses: actions/setup-python@v2
+      with:
+        python-version: '3.x'
+        cache: 'pip'
+        cache-dependency-path: '**/requirements-ci.txt'
+
+    - name: setup python (without cache)
+      if: ${{ env.ACT }}
+      id: setup-python-without-cache
+      uses: actions/setup-python@v2
+      with:
+        python-version: '3.x'
+
+    - name: install python dependencies
+      id: pip
+      run: |
+        echo "$HOME/.local/bin" >> $GITHUB_PATH
+        echo PYTHONPATH=$PWD >> $GITHUB_ENV
+        python -m pip install --upgrade pip-tools
+        python -m piptools sync requirements-ci.txt --pip-args '--disable-pip-version-check --no-cache-dir --user --no-warn-script-location --no-deps'
+        python -m pip install .
+
+    - name: pytest (e2e)
+      id: pytest-e2e
+      run: python -m pytest tests/e2e
+
   pre-documentation-scripts:
+    name: 'documentation / build script matrix'
     runs-on: 'ubuntu-latest'
 
     steps:
@@ -193,3 +234,18 @@ jobs:
 
     - name: runnable
       run: ./${{ matrix.script }}
+
+  release-quality:
+    name: releasable
+    runs-on: 'ubuntu-latest'
+    if: always()
+    needs:
+    - linting
+    - documentation-scripts
+    - test-e2e
+    - test-and-coverage
+
+    steps:
+    - uses: technote-space/workflow-conclusion-action@v2
+    - run: |
+        [[ "${{ env.WORKFLOW_CONCLUSION }}" == "success" ]] || exit 1
diff --git a/.gitignore b/.gitignore
index 9a6bcee9..47e3c1c8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,7 @@ coverage.xml
 *.event
 .env/
 .venv/
-script/tmp*
+**/tmp*
 *.log
 
 # automagically generated
diff --git a/grizzly/steps/background/setup.py b/grizzly/steps/background/setup.py
index c595cb3d..284a734e 100644
--- a/grizzly/steps/background/setup.py
+++ b/grizzly/steps/background/setup.py
@@ -149,7 +149,7 @@ def step_setup_set_global_context_variable(context: Context, variable: str, valu
     E.g. `Client ID` results in `client_id`.
 
     ```gherkin
-    And set global context variable "token.url" to "http://test.nu/api/auth"
+    And set global context variable "token.url" to "http://example.com/api/auth"
     And set global context variable "token/client_id" to "aaaa-bbbb-cccc-dddd"
     And set global context variable "token/client secret" to "aasdfasdfasdf=="
     And set global context variable "token.resource" to "0000-aaaaaaa-1111-1111-1111"
diff --git a/grizzly/steps/background/shapes.py b/grizzly/steps/background/shapes.py
index 38f58889..aff8b6b1 100644
--- a/grizzly/steps/background/shapes.py
+++ b/grizzly/steps/background/shapes.py
@@ -27,14 +27,15 @@ def step_shapes_user_count(context: Context, value: str, **kwargs: Dict[str, Any
     ```gherkin
     Given "5" users
     Given "1" user
-    Given "$conf::load.user.count"
+    Given "{{ user_count }}"
     ```
 
     Args:
         user_count (int): Number of users locust should create
     '''
     grizzly = cast(GrizzlyContext, context.grizzly)
-    should_resolve = '{{' in value and '}}' in value or value[0] == '$'
+    assert value[0] != '$', 'this expression does not support $conf or $env variables'
+    should_resolve = '{{' in value and '}}' in value
     user_count = int(round(float(resolve_variable(grizzly, value)), 0))
 
     if should_resolve and user_count < 1:
@@ -62,8 +63,9 @@ def step_shapes_spawn_rate(context: Context, value: str, **kwargs: Dict[str, Any
         spawn_rate (float): number of users per second
     '''
     assert isinstance(value, str), f'{value} is not a string'
+    assert value[0] != '$', 'this expression does not support $conf or $env variables'
     grizzly = cast(GrizzlyContext, context.grizzly)
-    should_resolve = '{{' in value and '}}' in value or value[0] == '$'
+    should_resolve = '{{' in value and '}}' in value
     spawn_rate = float(resolve_variable(grizzly, value))
 
     if should_resolve and spawn_rate < 0.01:
diff --git a/grizzly/steps/scenario/response.py b/grizzly/steps/scenario/response.py
index 1522d3d2..e5c4b98c 100644
--- a/grizzly/steps/scenario/response.py
+++ b/grizzly/steps/scenario/response.py
@@ -48,25 +48,24 @@ def step_response_save_matches(context: Context, target: ResponseTarget, express
 
     ```gherkin
     # only token is matched and saved in TOKEN, by using regexp match groups
-    And value of variable "TOKEN" is "none"
+    And value for variable "TOKEN" is "none"
     Then save response metadata "$.Authentication" that matches "Bearer (.*)$" in variabel "TOKEN"
 
     # the whole value is saved, as long as Authentication starts with "Bearer"
-    And value of variable "HEADER_AUTHENTICATION" is "none"
+    And value for variable "HEADER_AUTHENTICATION" is "none"
     Then save response metadata "$.Authentication" that matches "^Bearer .*$" in variable "HEADER_AUTHENTICATION"
 
     # only the numerical suffix is saved in the variable
-    And value of variable "AtomicIntegerIncrementer.measurermentId" is "1"
+    And value for variable "AtomicIntegerIncrementer.measurermentId" is "1"
     Then save response payload "$.measurement.id" that matches "^cpu([\\d]+)$" in "measurementId"
 
     # the whole value is saved, as long as the value starts with "cpu"
-    And value of variable "measurementId" is "0"
+    And value for variable "measurementId" is "0"
     Then save response payload "$.measurement.id" that matches "^cpu[\\d]+$" in "measurementId"
 
     # xpath example
-    And set response content type to "application/xml"
-    And value of variable "xmlMeasurementId" is "none"
-    Then save response payload "//measurement[0]/id/text()" that matches "^cpu[\\d]+$" in "xmlMeasurementId"
+    And value for variable "xmlMeasurementId" is "none"
+    Then save response payload "//measurement[0]/id/text() | content_type=xml" that matches "^cpu[\\d]+$" in "xmlMeasurementId"
     ```
 
     Args:
diff --git a/grizzly/steps/scenario/setup.py b/grizzly/steps/scenario/setup.py
index 2f4f34b7..1c8af15f 100644
--- a/grizzly/steps/scenario/setup.py
+++ b/grizzly/steps/scenario/setup.py
@@ -172,7 +172,7 @@ def step_setup_set_variable_alias(context: Context, alias: str, variable: str) -
     user, e.g. username and password.
 
     ```gherkin
-    And set variable "AtomicCsvRow.users" to "users.csv | repeat=True"
+    And value for variable "AtomicCsvRow.users" is "users.csv | repeat=True"
     And set alias "auth.user.username" for variable "AtomicCsvRow.users.username"
     And set alias "auth.user.password" for variable "AtomicCsvRow.users.password"
     ```
diff --git a/grizzly/steps/scenario/tasks.py b/grizzly/steps/scenario/tasks.py
index d864396a..fae6d779 100644
--- a/grizzly/steps/scenario/tasks.py
+++ b/grizzly/steps/scenario/tasks.py
@@ -7,7 +7,7 @@
 from ..helpers import add_request_task, get_task_client, is_template
 from ...types import RequestDirection, RequestMethod
 from ...context import GrizzlyContext
-from ...tasks import PrintTask, WaitTask, TransformerTask, UntilRequestTask, DateTask, AsyncRequestGroupTask
+from ...tasks import LogMessage, WaitTask, TransformerTask, UntilRequestTask, DateTask, AsyncRequestGroupTask
 
 from grizzly_extras.transformer import TransformerContentType
 
@@ -87,7 +87,7 @@ def step_task_request_text_with_name_to_endpoint(context: Context, method: Reque
             "test": "hello world"
         }
         """
-    Then put request with name "test-post" to endpoint "/api/test"
+    Then put request with name "test-put" to endpoint "/api/test"
         """
         {
             "test": "hello world"
@@ -269,11 +269,11 @@ def step_task_wait_seconds(context: Context, wait_time: float) -> None:
     ```gherkin
     And wait time inbetween requests is random between "1.5" and "2.5" seconds
     ...
-    And wait for "1.5" seconds
+    Then wait for "1.5" seconds
     ```
 
     Above combinations of steps will result in a wait time between 3 and 4 seconds for the first request that is defined after the
-    `And wait for...`-step.
+    `Then wait for...`-step.
 
     Args:
         wait_time (float): wait time in seconds
@@ -299,7 +299,7 @@ def step_task_print_message(context: Context, message: str) -> None:
     '''
 
     grizzly = cast(GrizzlyContext, context.grizzly)
-    grizzly.scenario.add_task(PrintTask(message=message))
+    grizzly.scenario.add_task(LogMessage(message=message))
 
 
 @then(u'parse "{content}" as "{content_type:ContentType}" and save value of "{expression}" in variable "{variable}"')
@@ -444,8 +444,14 @@ def step_task_async_group_start(context: Context, name: str) -> None:
 
     ```gherkin
     Given an async request group with name "async-group-1"
-    Then put "test-file.json" to "bs://my-storage?AccountKey=aaaabbb=&Container=my-container" as "uploaded-test-file.json"
-    Then get "https://www.example.org/example.json" and save response in "example_openapi"
+    Then post request with name "test-post-2" to endpoint "/api/test"
+        """
+        {
+            "value": "i have good news!"
+        }
+        """
+
+    Then get request with name "test-get-1" from endpoint "/api/test"
     And close async request group
     ```
 
@@ -469,8 +475,14 @@ def step_task_async_group_close(context: Context) -> None:
 
     ```gherkin
     Given an async request group with name "async-group-1"
-    Then put "test-file.json" to "bs://my-storage?AccountKey=aaaabbb=&Container=my-container" as "uploaded-test-file.json"
-    Then get "https://www.example.org/example.json" and save response in "example_openapi"
+    Then post request with name "test-post-2" to endpoint "/api/test"
+        """
+        {
+            "value": "i have good news!"
+        }
+        """
+
+    Then get request with name "test-get-1" from endpoint "/api/test"
     And close async request group
     ```
     '''
diff --git a/grizzly/steps/scenario/user.py b/grizzly/steps/scenario/user.py
index 01e07d60..e55ee1ba 100644
--- a/grizzly/steps/scenario/user.py
+++ b/grizzly/steps/scenario/user.py
@@ -43,7 +43,7 @@ def step_user_type(context: Context, user_class_name: str, host: str) -> None:
     '''Set which type of user the scenario should use and which host is the target.
 
     ```gherkin
-    Given a user of type "RestApi" load testing "http://api.google.com"
+    Given a user of type "RestApi" load testing "http://api.example.com"
     Given a user of type "MessageQueue" load testing "mq://mqm:secret@mq.example.com/?QueueManager=QMGR01&Channel=Channel01"
     Given a user of type "ServiceBus" load testing "sb://sb.example.com/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123def456ghi789="
     Given a user of type "BlobStorage" load testing "DefaultEndpointsProtocol=https;EndpointSuffix=core.windows.net;AccountName=examplestorage;AccountKey=xxxyyyyzzz=="
diff --git a/grizzly/tasks/__init__.py b/grizzly/tasks/__init__.py
index 09a076a0..841a3a4f 100644
--- a/grizzly/tasks/__init__.py
+++ b/grizzly/tasks/__init__.py
@@ -27,9 +27,12 @@ def get_templates(self) -> List[str]:
         def is_template(value: str) -> bool:
             return '{{' in value and '}}' in value
 
+        # tasks with no @template decorator
+        if not hasattr(self, '__template_attributes__'):
+            return []
+
         templates: Set[str] = set()
-        attributes = getattr(self, '__template_attributes__', [])
-        for attribute in attributes:
+        for attribute in self.__template_attributes__:
             value = getattr(self, attribute, None)
             if value is None:
                 continue
@@ -47,12 +50,18 @@ def is_template(value: str) -> bool:
                     templates.add(value)
             elif isinstance(value, list):
                 for list_value in value:
-                    if is_template(list_value):
+                    if isinstance(list_value, GrizzlyTask):
+                        templates.update(list_value.get_templates())
+                    elif is_template(list_value):
                         templates.add(list_value)
             elif isinstance(value, dict):
                 for dict_value in value.values():
-                    if is_template(dict_value):
+                    if isinstance(dict_value, GrizzlyTask):
+                        templates.update(dict_value.get_templates())
+                    elif is_template(dict_value):
                         templates.add(dict_value)
+            elif isinstance(value, GrizzlyTask):
+                templates.update(value.get_templates())
 
         return list(templates)
 
@@ -65,16 +74,15 @@ def __init__(self, attribute: str, *additional_attributes: str) -> None:
         if len(additional_attributes) > 0:
             self.attributes += list(additional_attributes)
 
-    def __call__(self, task: Type[GrizzlyTask]) -> Type[GrizzlyTask]:
-
-        setattr(task, '__template_attributes__', self.attributes)
+    def __call__(self, cls: Type[GrizzlyTask]) -> Type[GrizzlyTask]:
+        cls.__template_attributes__ = self.attributes
 
-        return task
+        return cls
 
 
 from .request import RequestTask, RequestTaskHandlers, RequestTaskResponse
 from .wait import WaitTask
-from .print import PrintTask
+from .log_message import LogMessage
 from .transformer import TransformerTask
 from .until import UntilRequestTask
 from .date import DateTask
@@ -85,7 +93,7 @@ def __call__(self, task: Type[GrizzlyTask]) -> Type[GrizzlyTask]:
     'RequestTaskHandlers',
     'RequestTaskResponse',
     'RequestTask',
-    'PrintTask',
+    'LogMessage',
     'WaitTask',
     'TransformerTask',
     'UntilRequestTask',
diff --git a/grizzly/tasks/async_group.py b/grizzly/tasks/async_group.py
index c1deb99a..6e06c683 100644
--- a/grizzly/tasks/async_group.py
+++ b/grizzly/tasks/async_group.py
@@ -17,6 +17,7 @@
 Enable `gevent` debugging for this task by running with argument `--verbose` and setting environment variable `GEVENT_MONITOR_THREAD_ENABLE`.
 '''
 import logging
+import inspect
 
 from os import environ
 
@@ -25,8 +26,7 @@
 from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple
 from time import perf_counter as time_perf_counter
 
-from . import GrizzlyTask, template
-from .request import RequestTask
+from . import GrizzlyTask, RequestTask, template
 from ..users.base import AsyncRequests
 
 if TYPE_CHECKING:  # pragma: no cover
@@ -34,7 +34,7 @@
     from ..scenarios import GrizzlyScenario
 
 
-@template('name')
+@template('name', 'requests')
 class AsyncRequestGroupTask(GrizzlyTask):
     requests: List[RequestTask]
 
@@ -49,8 +49,6 @@ def add(self, request: RequestTask) -> None:
         self.requests.append(request)
 
     def __call__(self) -> Callable[['GrizzlyScenario'], Any]:
-        import inspect
-
         def task(parent: 'GrizzlyScenario') -> Any:
             if not isinstance(parent.user, AsyncRequests):
                 raise NotImplementedError(f'{parent.user.__class__.__name__} does not inherit AsyncRequests')
diff --git a/grizzly/tasks/print.py b/grizzly/tasks/log_message.py
similarity index 96%
rename from grizzly/tasks/print.py
rename to grizzly/tasks/log_message.py
index 2f376f36..51a693e6 100644
--- a/grizzly/tasks/print.py
+++ b/grizzly/tasks/log_message.py
@@ -15,7 +15,7 @@
 
 
 @template('message')
-class PrintTask(GrizzlyTask):
+class LogMessage(GrizzlyTask):
     message: str
 
     def __init__(self, message: str, scenario: Optional['GrizzlyContextScenario'] = None) -> None:
diff --git a/grizzly/tasks/request.py b/grizzly/tasks/request.py
index a3410bd4..00bfe93f 100644
--- a/grizzly/tasks/request.py
+++ b/grizzly/tasks/request.py
@@ -21,7 +21,7 @@
 
 from ..types import RequestMethod
 # need to rename to avoid unused-import collision due to RequestTask.template ?!
-from . import GrizzlyTask, template as _template  # pylint: disable=unused-import
+from . import GrizzlyTask, template  # pylint: disable=unused-import
 
 if TYPE_CHECKING:  # pragma: no cover
     from ..context import GrizzlyContextScenario
@@ -64,7 +64,7 @@ def add_status_code(self, status: int) -> None:
                 self.status_codes.pop(index)
 
 
-@_template('name', 'endpoint', 'source')
+@template('name', 'endpoint', 'source')
 class RequestTask(GrizzlyTask):
     method: RequestMethod
     name: str
diff --git a/grizzly/tasks/until.py b/grizzly/tasks/until.py
index 89c30dd4..73b9c136 100644
--- a/grizzly/tasks/until.py
+++ b/grizzly/tasks/until.py
@@ -32,7 +32,7 @@
     from ..scenarios import GrizzlyScenario
 
 
-@template('condition', 'expression')
+@template('condition', 'request')
 class UntilRequestTask(GrizzlyTask):
     request: RequestTask
     condition: str
diff --git a/requirements-ci.txt b/requirements-ci.txt
index b5eb148b..b25760af 100644
--- a/requirements-ci.txt
+++ b/requirements-ci.txt
@@ -9,9 +9,9 @@ aenum==3.1.8 \
     --hash=sha256:859fe994719e6b5e39f15f73acd84e08b4e57dc642373b177a5fa6646798706a \
     --hash=sha256:8dbe15f446eb8264b788dfeca163fb0a043d408d212152397dc11377b851e4ae
     # via grizzly-loadtester (pyproject.toml)
-astroid==2.11.2 \
-    --hash=sha256:8d0a30fe6481ce919f56690076eafbb2fb649142a89dc874f1ec0e7a011492d0 \
-    --hash=sha256:cc8cc0d2d916c42d0a7c476c57550a4557a083081976bf42a73414322a6411d9
+astroid==2.11.4 \
+    --hash=sha256:561dc6015eecce7e696ff7e3b40434bc56831afeff783f0ea853e19c4f635c06 \
+    --hash=sha256:da0632b7c046d8361dfe1b1abb2e085a38624961fabe2997565a9c06c1be9d9a
     # via pylint
 astunparse==1.6.3 \
     --hash=sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872 \
@@ -38,9 +38,9 @@ azure-core==1.22.1 \
     #   azure-storage-blob
     #   grizzly-loadtester (pyproject.toml)
     #   opencensus-ext-azure
-azure-identity==1.9.0 \
-    --hash=sha256:0854d19da4c5644641814dc4f951c42e01400b5792f09dfb6bffa726d8b9160d \
-    --hash=sha256:2e75bbf0a72309b8f95f6769214b90bf271f3662d28d962bcddf4d2406157b51
+azure-identity==1.10.0 \
+    --hash=sha256:656e5034d9cef297cf9b35376ed620085273c18cfa52cea4a625bf0d5d2d6409 \
+    --hash=sha256:b386f1ccbea6a48b9ab7e7f162adc456793c345193a7c1a713959562b08dcbbd
     # via opencensus-ext-azure
 azure-servicebus==7.6.1 \
     --hash=sha256:c2ab7eb962959253f603189037d3deb8e1548b1b59b8150852137e599058ac12 \
@@ -68,22 +68,25 @@ backports-zoneinfo==0.2.1 ; python_version < "3.9" \
     --hash=sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac \
     --hash=sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2
     # via grizzly-loadtester (pyproject.toml)
-bcrypt==3.2.0 \
-    --hash=sha256:56e5da069a76470679f312a7d3d23deb3ac4519991a0361abc11da837087b61d \
-    --hash=sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29 \
-    --hash=sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7 \
-    --hash=sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34 \
-    --hash=sha256:a0584a92329210fcd75eb8a3250c5a941633f8bfaf2a18f81009b097732839b7 \
-    --hash=sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55 \
-    --hash=sha256:b589229207630484aefe5899122fb938a5b017b0f4349f769b8c13e78d99a8fd \
-    --hash=sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6 \
-    --hash=sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1 \
-    --hash=sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d
+bcrypt==3.2.2 \
+    --hash=sha256:2b02d6bfc6336d1094276f3f588aa1225a598e27f8e3388f4db9948cb707b521 \
+    --hash=sha256:433c410c2177057705da2a9f2cd01dd157493b2a7ac14c8593a16b3dab6b6bfb \
+    --hash=sha256:4e029cef560967fb0cf4a802bcf4d562d3d6b4b1bf81de5ec1abbe0f1adb027e \
+    --hash=sha256:61bae49580dce88095d669226d5076d0b9d927754cedbdf76c6c9f5099ad6f26 \
+    --hash=sha256:6d2cb9d969bfca5bc08e45864137276e4c3d3d7de2b162171def3d188bf9d34a \
+    --hash=sha256:7180d98a96f00b1050e93f5b0f556e658605dd9f524d0b0e68ae7944673f525e \
+    --hash=sha256:7d9ba2e41e330d2af4af6b1b6ec9e6128e91343d0b4afb9282e54e5508f31baa \
+    --hash=sha256:7ff2069240c6bbe49109fe84ca80508773a904f5a8cb960e02a977f7f519b129 \
+    --hash=sha256:88273d806ab3a50d06bc6a2fc7c87d737dd669b76ad955f449c43095389bc8fb \
+    --hash=sha256:a2c46100e315c3a5b90fdc53e429c006c5f962529bc27e1dfd656292c20ccc40 \
+    --hash=sha256:cd43303d6b8a165c29ec6756afd169faba9396a9472cdff753fe9f19b96ce2fa
     # via paramiko
 behave==1.2.6 \
     --hash=sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86 \
     --hash=sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c
-    # via grizzly-loadtester (pyproject.toml)
+    # via
+    #   grizzly-loadtester (pyproject.toml)
+    #   grizzly-loadtester-cli
 bleach==5.0.0 \
     --hash=sha256:08a1fe86d253b5c88c92cc3d810fd8048a16d15762e1e5b74d502256e5926aa1 \
     --hash=sha256:c6d6cc054bdc9c83b48b8083e236e5f00f238428666d2ce2e083eaa5fd568565
@@ -227,9 +230,9 @@ charset-normalizer==2.0.12 \
     --hash=sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597 \
     --hash=sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df
     # via requests
-click==8.1.2 \
-    --hash=sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e \
-    --hash=sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72
+click==8.1.3 \
+    --hash=sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e \
+    --hash=sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48
     # via
     #   flask
     #   mkdocs
@@ -310,6 +313,7 @@ cryptography==36.0.2 \
     # via
     #   azure-identity
     #   azure-storage-blob
+    #   grizzly-loadtester (pyproject.toml)
     #   msal
     #   paramiko
     #   pyjwt
@@ -366,9 +370,9 @@ flake8==4.0.1 \
     --hash=sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d \
     --hash=sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d
     # via grizzly-loadtester (pyproject.toml)
-flask==2.1.1 \
-    --hash=sha256:8a4cf32d904cf5621db9f0c9fbcd7efabf3003f22a04e4d0ce790c7137ec5264 \
-    --hash=sha256:a8c9bd3e558ec99646d177a9739c41df1ded0629480b4c8d2975412f3c9519c8
+flask==2.1.2 \
+    --hash=sha256:315ded2ddf8a6281567edb27393010fe3406188bafbfe65a3339d5787d89e477 \
+    --hash=sha256:fad5b446feb0d6db6aec0c3184d16a8c1f6c3e464b511649c8918a9be100b4fe
     # via
     #   flask-basicauth
     #   flask-cors
@@ -461,17 +465,17 @@ geventhttpclient==1.5.3 \
     --hash=sha256:fa290a202446630cc71593712bea548f296b89cb8a4161002c2e03afc3b2ffed \
     --hash=sha256:fbeb1ed7228fc406229c8693f8a02b8e064f7a9979665ac0181c983667cc383c
     # via locust
-ghp-import==2.0.2 \
-    --hash=sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46 \
-    --hash=sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071
+ghp-import==2.1.0 \
+    --hash=sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619 \
+    --hash=sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343
     # via mkdocs
-google-api-core==2.7.2 \
-    --hash=sha256:65480309a7437f739e4476da037af02a3ec8263f1d1f89f72bbdc8f54fe402d2 \
-    --hash=sha256:8fcbe52dc129fd83dca4e638a76f22b3a11579c493e643134e50e9870b233302
+google-api-core==2.7.3 \
+    --hash=sha256:17957f0704cbe95bd2ce25019efd2046423978594d181d4263e5dcffd2dbbc79 \
+    --hash=sha256:bb40d2d6412c77c367943b88d08323091d724deaea96892fff4a1ebd06f6e5be
     # via opencensus
-google-auth==2.6.5 \
-    --hash=sha256:04e224f241c0566477bb35a8a93be8c635210de743bde454d49393cfb605266d \
-    --hash=sha256:9a88ee548f6fd49467e2e443dfbfe10344e5a270629a137a3a0b3437ec6b02a6
+google-auth==2.6.6 \
+    --hash=sha256:1ba4938e032b73deb51e59c4656a00e0939cf0b1112575099f136babb4563312 \
+    --hash=sha256:349ac49b18b01019453cc99c11c92ed772739778c92f184002b7ab3a5b7ac77d
     # via google-api-core
 googleapis-common-protos==1.56.0 \
     --hash=sha256:4007500795bcfc269d279f0f7d253ae18d6dc1ff5d5a73613ffe452038b1ec5f \
@@ -534,6 +538,10 @@ greenlet==1.1.2 \
     --hash=sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c \
     --hash=sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b
     # via gevent
+grizzly-loadtester-cli==3.0.1 \
+    --hash=sha256:779ee76f6e8cdb0de98596d2af8791b71535c288b990198c3a76ed941ca76ea2 \
+    --hash=sha256:870ada30fe5b50549a0e573036b0da363e03be5f9d5238d49275b902f3d1d7d4
+    # via grizzly-loadtester (pyproject.toml)
 idna==3.3 \
     --hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \
     --hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d
@@ -581,6 +589,7 @@ jinja2==3.0.3 \
     # via
     #   flask
     #   grizzly-loadtester (pyproject.toml)
+    #   grizzly-loadtester-cli
     #   locust
     #   mkdocs
     #   mkdocs-material
@@ -765,9 +774,9 @@ mkdocs==1.3.0 \
     # via
     #   grizzly-loadtester (pyproject.toml)
     #   mkdocs-material
-mkdocs-material==8.2.9 \
-    --hash=sha256:c177ff180b024bc061714c9483a8d26d36e1b9fdef4be8e70e243770416fe9d7 \
-    --hash=sha256:fbe39baa57c70fdbe9d1a24c6c2d0625e255e74f22b20aff43abb64157446f4d
+mkdocs-material==8.2.13 \
+    --hash=sha256:2666f1d7d6a8dc28dda1e777f77add12799e66bd00250de99914a33525763816 \
+    --hash=sha256:505408fe001d668543236f5db5a88771460ad83ef7b58826630cc1f8b7e63099
     # via grizzly-loadtester (pyproject.toml)
 mkdocs-material-extensions==1.0.3 \
     --hash=sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44 \
@@ -779,9 +788,9 @@ msal==1.17.0 \
     # via
     #   azure-identity
     #   msal-extensions
-msal-extensions==0.3.1 \
-    --hash=sha256:89df9c0237e1adf16938fa58575db59c2bb9de04a83ffb0452c8dfc79031f717 \
-    --hash=sha256:d9029af70f2cbdc5ad7ecfed61cb432ebe900484843ccf72825445dbfe62d311
+msal-extensions==1.0.0 \
+    --hash=sha256:91e3db9620b822d0ed2b4d1850056a0f133cba04455e62f11612e40f5502f2ee \
+    --hash=sha256:c676aba56b0cce3783de1b5c5ecfe828db998167875126ca4b47dc6436451354
     # via azure-identity
 msgpack==1.0.3 \
     --hash=sha256:0d8c332f53ffff01953ad25131272506500b14750c1d0ce8614b17d098252fbc \
@@ -827,30 +836,30 @@ msrest==0.6.21 \
     # via
     #   azure-servicebus
     #   azure-storage-blob
-mypy==0.942 \
-    --hash=sha256:0e2dd88410937423fba18e57147dd07cd8381291b93d5b1984626f173a26543e \
-    --hash=sha256:10daab80bc40f84e3f087d896cdb53dc811a9f04eae4b3f95779c26edee89d16 \
-    --hash=sha256:17e44649fec92e9f82102b48a3bf7b4a5510ad0cd22fa21a104826b5db4903e2 \
-    --hash=sha256:1a0459c333f00e6a11cbf6b468b870c2b99a906cb72d6eadf3d1d95d38c9352c \
-    --hash=sha256:246e1aa127d5b78488a4a0594bd95f6d6fb9d63cf08a66dafbff8595d8891f67 \
-    --hash=sha256:2b184db8c618c43c3a31b32ff00cd28195d39e9c24e7c3b401f3db7f6e5767f5 \
-    --hash=sha256:2bc249409a7168d37c658e062e1ab5173300984a2dada2589638568ddc1db02b \
-    --hash=sha256:3841b5433ff936bff2f4dc8d54cf2cdbfea5d8e88cedfac45c161368e5770ba6 \
-    --hash=sha256:4c3e497588afccfa4334a9986b56f703e75793133c4be3a02d06a3df16b67a58 \
-    --hash=sha256:5bf44840fb43ac4074636fd47ee476d73f0039f4f54e86d7265077dc199be24d \
-    --hash=sha256:64235137edc16bee6f095aba73be5334677d6f6bdb7fa03cfab90164fa294a17 \
-    --hash=sha256:6776e5fa22381cc761df53e7496a805801c1a751b27b99a9ff2f0ca848c7eca0 \
-    --hash=sha256:6ce34a118d1a898f47def970a2042b8af6bdcc01546454726c7dd2171aa6dfca \
-    --hash=sha256:6f6ad963172152e112b87cc7ec103ba0f2db2f1cd8997237827c052a3903eaa6 \
-    --hash=sha256:6f7106cbf9cc2f403693bf50ed7c9fa5bb3dfa9007b240db3c910929abe2a322 \
-    --hash=sha256:7742d2c4e46bb5017b51c810283a6a389296cda03df805a4f7869a6f41246534 \
-    --hash=sha256:9521c1265ccaaa1791d2c13582f06facf815f426cd8b07c3a485f486a8ffc1f3 \
-    --hash=sha256:a1b383fe99678d7402754fe90448d4037f9512ce70c21f8aee3b8bf48ffc51db \
-    --hash=sha256:b840cfe89c4ab6386c40300689cd8645fc8d2d5f20101c7f8bd23d15fca14904 \
-    --hash=sha256:d8d3ba77e56b84cd47a8ee45b62c84b6d80d32383928fe2548c9a124ea0a725c \
-    --hash=sha256:dcd955f36e0180258a96f880348fbca54ce092b40fbb4b37372ae3b25a0b0a46 \
-    --hash=sha256:e865fec858d75b78b4d63266c9aff770ecb6a39dfb6d6b56c47f7f8aba6baba8 \
-    --hash=sha256:edf7237137a1a9330046dbb14796963d734dd740a98d5e144a3eb1d267f5f9ee
+mypy==0.950 \
+    --hash=sha256:0112752a6ff07230f9ec2f71b0d3d4e088a910fdce454fdb6553e83ed0eced7d \
+    --hash=sha256:0384d9f3af49837baa92f559d3fa673e6d2652a16550a9ee07fc08c736f5e6f8 \
+    --hash=sha256:1b333cfbca1762ff15808a0ef4f71b5d3eed8528b23ea1c3fb50543c867d68de \
+    --hash=sha256:1fdeb0a0f64f2a874a4c1f5271f06e40e1e9779bf55f9567f149466fc7a55038 \
+    --hash=sha256:4c653e4846f287051599ed8f4b3c044b80e540e88feec76b11044ddc5612ffed \
+    --hash=sha256:563514c7dc504698fb66bb1cf897657a173a496406f1866afae73ab5b3cdb334 \
+    --hash=sha256:5b231afd6a6e951381b9ef09a1223b1feabe13625388db48a8690f8daa9b71ff \
+    --hash=sha256:5ce6a09042b6da16d773d2110e44f169683d8cc8687e79ec6d1181a72cb028d2 \
+    --hash=sha256:5e7647df0f8fc947388e6251d728189cfadb3b1e558407f93254e35abc026e22 \
+    --hash=sha256:6003de687c13196e8a1243a5e4bcce617d79b88f83ee6625437e335d89dfebe2 \
+    --hash=sha256:61504b9a5ae166ba5ecfed9e93357fd51aa693d3d434b582a925338a2ff57fd2 \
+    --hash=sha256:77423570c04aca807508a492037abbd72b12a1fb25a385847d191cd50b2c9605 \
+    --hash=sha256:a4d9898f46446bfb6405383b57b96737dcfd0a7f25b748e78ef3e8c576bba3cb \
+    --hash=sha256:a952b8bc0ae278fc6316e6384f67bb9a396eb30aced6ad034d3a76120ebcc519 \
+    --hash=sha256:b5b5bd0ffb11b4aba2bb6d31b8643902c48f990cc92fda4e21afac658044f0c0 \
+    --hash=sha256:ca75ecf2783395ca3016a5e455cb322ba26b6d33b4b413fcdedfc632e67941dc \
+    --hash=sha256:cf9c261958a769a3bd38c3e133801ebcd284ffb734ea12d01457cb09eacf7d7b \
+    --hash=sha256:dd4d670eee9610bf61c25c940e9ade2d0ed05eb44227275cce88701fee014b1f \
+    --hash=sha256:e19736af56947addedce4674c0971e5dceef1b5ec7d667fe86bcd2b07f8f9075 \
+    --hash=sha256:eaea21d150fb26d7b4856766e7addcf929119dd19fc832b22e71d942835201ef \
+    --hash=sha256:eaff8156016487c1af5ffa5304c3e3fd183edcb412f3e9c72db349faf3f6e0eb \
+    --hash=sha256:ee0a36edd332ed2c5208565ae6e3a7afc0eabb53f5327e281f2ef03a6bc7687a \
+    --hash=sha256:ef7beb2a3582eb7a9f37beaf38a28acfd801988cde688760aea9e6cc4832b10b
     # via grizzly-loadtester (pyproject.toml)
 mypy-extensions==0.4.3 \
     --hash=sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d \
@@ -858,9 +867,9 @@ mypy-extensions==0.4.3 \
     # via
     #   grizzly-loadtester (pyproject.toml)
     #   mypy
-nr-util==0.8.10 \
-    --hash=sha256:2a7c470aac00248d0f9b00c5fd8af40e22b59ed6e79a2a64b628648c0cc7b569 \
-    --hash=sha256:48a1d5af51f7e1e86cd3534cd464519edc9f76ec1cc6c1a2cead795bc8b6bfc9
+nr-util==0.8.11 \
+    --hash=sha256:2513458a7bffca4b67b0c19aabf09792d2062faed59c214b54e3b17d3c50c4c9 \
+    --hash=sha256:fc4562d41cf1276d469cbd2797d02886e52133f12db1145ac9a209a44181c601
     # via
     #   databind-core
     #   databind-json
@@ -870,28 +879,29 @@ oauthlib==3.2.0 \
     --hash=sha256:23a8208d75b902797ea29fd31fa80a15ed9dc2c6c16fe73f5d346f83f6fa27a2 \
     --hash=sha256:6db33440354787f9b7f3a6dbd4febf5d0f93758354060e802f6c06cb493022fe
     # via requests-oauthlib
-opencensus==0.8.0 \
-    --hash=sha256:476d99fb82f3807acf566e6c232049dc13e825102be104e6231784abcc1b1a5e \
-    --hash=sha256:7a60bdbceb370a379bccad9ea9d1d56fcce650cf12a6d2b129e6880d2632eeab
+opencensus==0.9.0 \
+    --hash=sha256:49282040bee3c9e83a55f96c30faecb6cd4b47916edceb1c180b04a6b66b6c4c \
+    --hash=sha256:6af715a27d38775b4710c53eb611614b9761cc118c3906b0074ea85ce6a673f9
     # via opencensus-ext-azure
 opencensus-context==0.1.2 \
     --hash=sha256:8bfb461d18f1dd243420224603c71544d7ac888bb236cf0d0a58545942561478 \
     --hash=sha256:8cf64ce901e6b35f491bd1521e6541676e034c52d00c95500e9364600f835c06
     # via opencensus
-opencensus-ext-azure==1.1.3 \
-    --hash=sha256:70ad2601ef24e655d1fd5e6e53f57599f919ca2b943c5371c8a794da379a5fe8 \
-    --hash=sha256:fba89a747dfee7ddc1a23875ba0b7e77f5ae73540336079901ffcd7d6141d5dc
+opencensus-ext-azure==1.1.4 \
+    --hash=sha256:6945413848c04cc72b214d7ddf083115a00e3b54e8d963d0efe71ccb9a3f3cdc \
+    --hash=sha256:7560b133674cd74b88ed583655c4c441e672b07315b81c4986a76bde0a517f64
     # via grizzly-loadtester (pyproject.toml)
 packaging==21.3 \
     --hash=sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb \
     --hash=sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522
     # via
     #   build
+    #   grizzly-loadtester-cli
     #   mkdocs
     #   pytest
-paramiko==2.10.3 \
-    --hash=sha256:ac6593479f2b47a9422eca076b22cff9f795495e6733a64723efc75dd8c92101 \
-    --hash=sha256:ddb1977853aef82804b35d72a0e597b244fa326c404c350bd00c5b01dbfee71a
+paramiko==2.10.4 \
+    --hash=sha256:3c9ed6084f4b671ab66dc3c729092d32d96c3258f1426071301cb33654b09027 \
+    --hash=sha256:3d2e650b6812ce6d160abff701d6ef4434ec97934b13e95cf1ad3da70ffb5c58
     # via grizzly-loadtester (pyproject.toml)
 parse==1.19.0 \
     --hash=sha256:9ff82852bcb65d139813e2a5197627a94966245c897796760a3a2a8eb66f020b
@@ -932,31 +942,31 @@ portalocker==2.4.0 \
     --hash=sha256:a648ad761b8ea27370cb5915350122cd807b820d2193ed5c9cc28f163df637f4 \
     --hash=sha256:b092f48e1e30a234ab3dd1cfd44f2f235e8a41f4e310e463fc8d6798d1c3c235
     # via msal-extensions
-protobuf==3.20.0 \
-    --hash=sha256:001c2160c03b6349c04de39cf1a58e342750da3632f6978a1634a3dcca1ec10e \
-    --hash=sha256:0b250c60256c8824219352dc2a228a6b49987e5bf94d3ffcf4c46585efcbd499 \
-    --hash=sha256:1d24c81c2310f0063b8fc1c20c8ed01f3331be9374b4b5c2de846f69e11e21fb \
-    --hash=sha256:1eb13f5a5a59ca4973bcfa2fc8fff644bd39f2109c3f7a60bd5860cb6a49b679 \
-    --hash=sha256:25d2fcd6eef340082718ec9ad2c58d734429f2b1f7335d989523852f2bba220b \
-    --hash=sha256:32bf4a90c207a0b4e70ca6dd09d43de3cb9898f7d5b69c2e9e3b966a7f342820 \
-    --hash=sha256:38fd9eb74b852e4ee14b16e9670cd401d147ee3f3ec0d4f7652e0c921d6227f8 \
-    --hash=sha256:47257d932de14a7b6c4ae1b7dbf592388153ee35ec7cae216b87ae6490ed39a3 \
-    --hash=sha256:4eda68bd9e2a4879385e6b1ea528c976f59cd9728382005cc54c28bcce8db983 \
-    --hash=sha256:52bae32a147c375522ce09bd6af4d2949aca32a0415bc62df1456b3ad17c6001 \
-    --hash=sha256:542f25a4adf3691a306dcc00bf9a73176554938ec9b98f20f929a044f80acf1b \
-    --hash=sha256:5b5860b790498f233cdc8d635a17fc08de62e59d4dcd8cdb6c6c0d38a31edf2b \
-    --hash=sha256:6efe066a7135233f97ce51a1aa007d4fb0be28ef093b4f88dac4ad1b3a2b7b6f \
-    --hash=sha256:71b2c3d1cd26ed1ec7c8196834143258b2ad7f444efff26fdc366c6f5e752702 \
-    --hash=sha256:7a53d4035427b9dbfbb397f46642754d294f131e93c661d056366f2a31438263 \
-    --hash=sha256:7dcd84dc31ebb35ade755e06d1561d1bd3b85e85dbdbf6278011fc97b22810db \
-    --hash=sha256:88c8be0558bdfc35e68c42ae5bf785eb9390d25915d4863bbc7583d23da77074 \
-    --hash=sha256:8be43a91ab66fe995e85ccdbdd1046d9f0443d59e060c0840319290de25b7d33 \
-    --hash=sha256:8d84453422312f8275455d1cb52d850d6a4d7d714b784e41b573c6f5bfc2a029 \
-    --hash=sha256:9d0f3aca8ca51c8b5e204ab92bd8afdb2a8e3df46bd0ce0bd39065d79aabcaa4 \
-    --hash=sha256:a1eebb6eb0653e594cb86cd8e536b9b083373fca9aba761ade6cd412d46fb2ab \
-    --hash=sha256:bc14037281db66aa60856cd4ce4541a942040686d290e3f3224dd3978f88f554 \
-    --hash=sha256:fbcbb068ebe67c4ff6483d2e2aa87079c325f8470b24b098d6bf7d4d21d57a69 \
-    --hash=sha256:fd7133b885e356fa4920ead8289bb45dc6f185a164e99e10279f33732ed5ce15
+protobuf==3.20.1 \
+    --hash=sha256:06059eb6953ff01e56a25cd02cca1a9649a75a7e65397b5b9b4e929ed71d10cf \
+    --hash=sha256:097c5d8a9808302fb0da7e20edf0b8d4703274d140fd25c5edabddcde43e081f \
+    --hash=sha256:284f86a6207c897542d7e956eb243a36bb8f9564c1742b253462386e96c6b78f \
+    --hash=sha256:32ca378605b41fd180dfe4e14d3226386d8d1b002ab31c969c366549e66a2bb7 \
+    --hash=sha256:3cc797c9d15d7689ed507b165cd05913acb992d78b379f6014e013f9ecb20996 \
+    --hash=sha256:62f1b5c4cd6c5402b4e2d63804ba49a327e0c386c99b1675c8a0fefda23b2067 \
+    --hash=sha256:69ccfdf3657ba59569c64295b7d51325f91af586f8d5793b734260dfe2e94e2c \
+    --hash=sha256:6f50601512a3d23625d8a85b1638d914a0970f17920ff39cec63aaef80a93fb7 \
+    --hash=sha256:7403941f6d0992d40161aa8bb23e12575637008a5a02283a930addc0508982f9 \
+    --hash=sha256:755f3aee41354ae395e104d62119cb223339a8f3276a0cd009ffabfcdd46bb0c \
+    --hash=sha256:77053d28427a29987ca9caf7b72ccafee011257561259faba8dd308fda9a8739 \
+    --hash=sha256:7e371f10abe57cee5021797126c93479f59fccc9693dafd6bd5633ab67808a91 \
+    --hash=sha256:9016d01c91e8e625141d24ec1b20fed584703e527d28512aa8c8707f105a683c \
+    --hash=sha256:9be73ad47579abc26c12024239d3540e6b765182a91dbc88e23658ab71767153 \
+    --hash=sha256:adc31566d027f45efe3f44eeb5b1f329da43891634d61c75a5944e9be6dd42c9 \
+    --hash=sha256:adfc6cf69c7f8c50fd24c793964eef18f0ac321315439d94945820612849c388 \
+    --hash=sha256:af0ebadc74e281a517141daad9d0f2c5d93ab78e9d455113719a45a49da9db4e \
+    --hash=sha256:cb29edb9eab15742d791e1025dd7b6a8f6fcb53802ad2f6e3adcb102051063ab \
+    --hash=sha256:cd68be2559e2a3b84f517fb029ee611546f7812b1fdd0aa2ecc9bc6ec0e4fdde \
+    --hash=sha256:cdee09140e1cd184ba9324ec1df410e7147242b94b5f8b0c64fc89e38a8ba531 \
+    --hash=sha256:db977c4ca738dd9ce508557d4fce0f5aebd105e158c725beec86feb1f6bc20d8 \
+    --hash=sha256:dd5789b2948ca702c17027c84c2accb552fc30f4622a98ab5c51fcfe8c50d3e7 \
+    --hash=sha256:e250a42f15bf9d5b09fe1b293bdba2801cd520a9f5ea2d7fb7536d4441811d20 \
+    --hash=sha256:ff8d8fa42675249bb456f5db06c00de6c2f4c27a065955917b28c4f15978b9c3
     # via
     #   google-api-core
     #   googleapis-common-protos
@@ -1026,9 +1036,9 @@ pyflakes==2.4.0 \
     --hash=sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c \
     --hash=sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e
     # via flake8
-pygments==2.11.2 \
-    --hash=sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65 \
-    --hash=sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a
+pygments==2.12.0 \
+    --hash=sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb \
+    --hash=sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519
     # via
     #   mkdocs-material
     #   readme-renderer
@@ -1036,13 +1046,13 @@ pyjwt[crypto]==2.3.0 \
     --hash=sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41 \
     --hash=sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f
     # via msal
-pylint==2.13.5 \
-    --hash=sha256:c149694cfdeaee1aa2465e6eaab84c87a881a7d55e6e93e09466be7164764d1e \
-    --hash=sha256:dab221658368c7a05242e673c275c488670144123f4bd262b2777249c1c0de9b
+pylint==2.13.8 \
+    --hash=sha256:ced8968c3b699df0615e2a709554dec3ddac2f5cd06efadb69554a69eeca364a \
+    --hash=sha256:f87e863a0b08f64b5230e7e779bcb75276346995737b2c0dc2793070487b1ff6
     # via grizzly-loadtester (pyproject.toml)
-pymdown-extensions==9.3 \
-    --hash=sha256:a80553b243d3ed2d6c27723bcd64ca9887e560e6f4808baa96f36e93061eaf90 \
-    --hash=sha256:b37461a181c1c8103cfe1660081726a0361a8294cbfda88e5b02cefe976f0546
+pymdown-extensions==9.4 \
+    --hash=sha256:1baa22a60550f731630474cad28feb0405c8101f1a7ddc3ec0ed86ee510bcc43 \
+    --hash=sha256:5b7432456bf555ce2b0ab3c2439401084cda8110f24f6b3ecef952b8313dfa1b
     # via mkdocs-material
 pynacl==1.5.0 \
     --hash=sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858 \
@@ -1060,9 +1070,9 @@ pyparsing==3.0.8 \
     --hash=sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954 \
     --hash=sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06
     # via packaging
-pytest==7.1.1 \
-    --hash=sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63 \
-    --hash=sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea
+pytest==7.1.2 \
+    --hash=sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c \
+    --hash=sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45
     # via
     #   grizzly-loadtester (pyproject.toml)
     #   pytest-cov
@@ -1181,9 +1191,9 @@ pyzmq==22.3.0 \
     # via
     #   grizzly-loadtester (pyproject.toml)
     #   locust
-readme-renderer==34.0 \
-    --hash=sha256:262510fe6aae81ed4e94d8b169077f325614c0b1a45916a80442c6576264a9c2 \
-    --hash=sha256:dfb4d17f21706d145f7473e0b61ca245ba58e810cf9b2209a48239677f82e5b0
+readme-renderer==35.0 \
+    --hash=sha256:73b84905d091c31f36e50b4ae05ae2acead661f6a09a9abb4df7d2ddcdb6a698 \
+    --hash=sha256:a727999acfc222fc21d82a12ed48c957c4989785e5865807c65a487d21677497
     # via twine
 requests==2.27.1 \
     --hash=sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61 \
@@ -1191,6 +1201,7 @@ requests==2.27.1 \
     # via
     #   azure-core
     #   google-api-core
+    #   grizzly-loadtester-cli
     #   influxdb
     #   locust
     #   msal
@@ -1304,7 +1315,6 @@ six==1.16.0 \
     #   azure-core
     #   azure-identity
     #   azure-servicebus
-    #   bcrypt
     #   behave
     #   bleach
     #   flask-cors
@@ -1344,9 +1354,9 @@ types-backports==0.1.3 \
     --hash=sha256:dafcd61848081503e738a7768872d1dd6c018401b4d2a1cfb608ea87ec9864b9 \
     --hash=sha256:f4b7206c073df88d6200891e3d27506185fd60cda66fb289737b2fa92c0010cf
     # via grizzly-loadtester (pyproject.toml)
-types-cryptography==3.3.20 \
-    --hash=sha256:025da60274ce119e16317aa2b4822647212791b5c2e39b028571862d95fdf851 \
-    --hash=sha256:bbef5b66972596f3cd61b3091719fc935e081f3bba87ecea561bc0dbb1678753
+types-cryptography==3.3.21 \
+    --hash=sha256:ad1b9c63159c009f8676c7e41a4d595dfb96e8c03affa2e693e1617908bb409e \
+    --hash=sha256:bdeb6dd07280ac724e05f02e0d8ef01fdef729b18bb07d635d64de83171a4e70
     # via types-paramiko
 types-jinja2==2.11.9 \
     --hash=sha256:60a1e21e8296979db32f9374d8a239af4cb541ff66447bb915d8ad398f9c63b2 \
@@ -1356,25 +1366,25 @@ types-markupsafe==1.1.10 \
     --hash=sha256:85b3a872683d02aea3a5ac2a8ef590193c344092032f58457287fbf8e06711b1 \
     --hash=sha256:ca2bee0f4faafc45250602567ef38d533e877d2ddca13003b319c551ff5b3cc5
     # via types-jinja2
-types-paramiko==2.8.19 \
-    --hash=sha256:276c9ad551313e87ba2c4f0997e3ad2e31cbdd3e4abf10247d43cd973963900e \
-    --hash=sha256:52192d843664ef7c1f87b9fe0697bbbb5a8f812e43016944cfeabec7ddeca423
+types-paramiko==2.10.0 \
+    --hash=sha256:ab6893d5fce5ed06964d61939ed6a7168ab14952945a90995a628a5e82a5e161 \
+    --hash=sha256:f558bb26ff3b085f7282125559927ebf9e1c08410cd4aac5766addbf4d15c8bc
     # via grizzly-loadtester (pyproject.toml)
-types-python-dateutil==2.8.11 \
-    --hash=sha256:61222fa5518b14601a4e95ad4f17feea4ada904947610560336cf8921a7544a8 \
-    --hash=sha256:edc05d0b93201928ab0b5ff2b314413dd3d2b24eb12c470297ef929de77c93ef
+types-python-dateutil==2.8.14 \
+    --hash=sha256:367c1ffa1a52a4b2a534c9b30b40b01f2b7f2ea47a97fd821f6c76d1c7ae3129 \
+    --hash=sha256:6ac40f66cf23a5200bd179b78d6be52e81a7750e4f8e45d7cfaf3ddda60ff01f
     # via grizzly-loadtester (pyproject.toml)
 types-pyyaml==5.4.12 \
     --hash=sha256:3f4daa754357491625ae8c3a39c9e1b0d7cd5632bc4e1c35e7a7f75a64aa124b \
     --hash=sha256:e06083f85375a5678e4c19452ed6467ce2167b71db222313e1792cb8fc76859a
     # via grizzly-loadtester (pyproject.toml)
-types-requests==2.27.19 \
-    --hash=sha256:795e378117088d1e4bf41a2c01a153b73d6ea40aa9d7c0ac753abde84c0d3a8f \
-    --hash=sha256:c6c5384677d98f212516de50c4b2c38ef659b93008fbc5bb4b81726138bc8485
+types-requests==2.27.25 \
+    --hash=sha256:2444905c89731dbcb6bbcd6d873a04252445df7623917c640e463b2b28d2a708 \
+    --hash=sha256:805ae7e38fd9d157153066dc4381cf585fd34dfa212f2fc1fece248c05aac571
     # via grizzly-loadtester (pyproject.toml)
-types-urllib3==1.26.13 \
-    --hash=sha256:40f8fb5e8cd7d57e8aefdee3fdd5e930aa1a1bb4179cdadd55226cea588af790 \
-    --hash=sha256:ff7500641824f881b2c7bde4cc57e6c3abf03d1e005bae83aca752e77213a5da
+types-urllib3==1.26.14 \
+    --hash=sha256:2a2578e4b36341ccd240b00fccda9826988ff0589a44ba4a664bbd69ef348d27 \
+    --hash=sha256:5d2388aa76395b1e3999ff789ea5b3283677dad8e9bcf3d9117ba19271fd35d9
     # via types-requests
 typing-extensions==3.10.0.2 \
     --hash=sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e \
@@ -1459,9 +1469,9 @@ webencodings==0.5.1 \
     --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
     --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923
     # via bleach
-werkzeug==2.1.1 \
-    --hash=sha256:3c5493ece8268fecdcdc9c0b112211acd006354723b280d643ec732b6d4063d6 \
-    --hash=sha256:f8e89a20aeabbe8a893c24a461d3ee5dad2123b05cc6abd73ceed01d39c3ae74
+werkzeug==2.1.2 \
+    --hash=sha256:1ce08e8093ed67d638d63879fd1ba3735817f7a80de3674d293f5984f25fb6e6 \
+    --hash=sha256:72a4b735692dd3135217911cbeaa1be5fa3f62bffb8745c5215420a03dc55255
     # via
     #   flask
     #   locust
@@ -1471,71 +1481,71 @@ wheel==0.37.1 \
     # via
     #   astunparse
     #   pip-tools
-wrapt==1.14.0 \
-    --hash=sha256:00108411e0f34c52ce16f81f1d308a571df7784932cc7491d1e94be2ee93374b \
-    --hash=sha256:01f799def9b96a8ec1ef6b9c1bbaf2bbc859b87545efbecc4a78faea13d0e3a0 \
-    --hash=sha256:09d16ae7a13cff43660155383a2372b4aa09109c7127aa3f24c3cf99b891c330 \
-    --hash=sha256:14e7e2c5f5fca67e9a6d5f753d21f138398cad2b1159913ec9e9a67745f09ba3 \
-    --hash=sha256:167e4793dc987f77fd476862d32fa404d42b71f6a85d3b38cbce711dba5e6b68 \
-    --hash=sha256:1807054aa7b61ad8d8103b3b30c9764de2e9d0c0978e9d3fc337e4e74bf25faa \
-    --hash=sha256:1f83e9c21cd5275991076b2ba1cd35418af3504667affb4745b48937e214bafe \
-    --hash=sha256:21b1106bff6ece8cb203ef45b4f5778d7226c941c83aaaa1e1f0f4f32cc148cd \
-    --hash=sha256:22626dca56fd7f55a0733e604f1027277eb0f4f3d95ff28f15d27ac25a45f71b \
-    --hash=sha256:23f96134a3aa24cc50614920cc087e22f87439053d886e474638c68c8d15dc80 \
-    --hash=sha256:2498762814dd7dd2a1d0248eda2afbc3dd9c11537bc8200a4b21789b6df6cd38 \
-    --hash=sha256:28c659878f684365d53cf59dc9a1929ea2eecd7ac65da762be8b1ba193f7e84f \
-    --hash=sha256:2eca15d6b947cfff51ed76b2d60fd172c6ecd418ddab1c5126032d27f74bc350 \
-    --hash=sha256:354d9fc6b1e44750e2a67b4b108841f5f5ea08853453ecbf44c81fdc2e0d50bd \
-    --hash=sha256:36a76a7527df8583112b24adc01748cd51a2d14e905b337a6fefa8b96fc708fb \
-    --hash=sha256:3a0a4ca02752ced5f37498827e49c414d694ad7cf451ee850e3ff160f2bee9d3 \
-    --hash=sha256:3a71dbd792cc7a3d772ef8cd08d3048593f13d6f40a11f3427c000cf0a5b36a0 \
-    --hash=sha256:3a88254881e8a8c4784ecc9cb2249ff757fd94b911d5df9a5984961b96113fff \
-    --hash=sha256:47045ed35481e857918ae78b54891fac0c1d197f22c95778e66302668309336c \
-    --hash=sha256:4775a574e9d84e0212f5b18886cace049a42e13e12009bb0491562a48bb2b758 \
-    --hash=sha256:493da1f8b1bb8a623c16552fb4a1e164c0200447eb83d3f68b44315ead3f9036 \
-    --hash=sha256:4b847029e2d5e11fd536c9ac3136ddc3f54bc9488a75ef7d040a3900406a91eb \
-    --hash=sha256:59d7d92cee84a547d91267f0fea381c363121d70fe90b12cd88241bd9b0e1763 \
-    --hash=sha256:5a0898a640559dec00f3614ffb11d97a2666ee9a2a6bad1259c9facd01a1d4d9 \
-    --hash=sha256:5a9a1889cc01ed2ed5f34574c90745fab1dd06ec2eee663e8ebeefe363e8efd7 \
-    --hash=sha256:5b835b86bd5a1bdbe257d610eecab07bf685b1af2a7563093e0e69180c1d4af1 \
-    --hash=sha256:5f24ca7953f2643d59a9c87d6e272d8adddd4a53bb62b9208f36db408d7aafc7 \
-    --hash=sha256:61e1a064906ccba038aa3c4a5a82f6199749efbbb3cef0804ae5c37f550eded0 \
-    --hash=sha256:65bf3eb34721bf18b5a021a1ad7aa05947a1767d1aa272b725728014475ea7d5 \
-    --hash=sha256:6807bcee549a8cb2f38f73f469703a1d8d5d990815c3004f21ddb68a567385ce \
-    --hash=sha256:68aeefac31c1f73949662ba8affaf9950b9938b712fb9d428fa2a07e40ee57f8 \
-    --hash=sha256:6915682f9a9bc4cf2908e83caf5895a685da1fbd20b6d485dafb8e218a338279 \
-    --hash=sha256:6d9810d4f697d58fd66039ab959e6d37e63ab377008ef1d63904df25956c7db0 \
-    --hash=sha256:729d5e96566f44fccac6c4447ec2332636b4fe273f03da128fff8d5559782b06 \
-    --hash=sha256:748df39ed634851350efa87690c2237a678ed794fe9ede3f0d79f071ee042561 \
-    --hash=sha256:763a73ab377390e2af26042f685a26787c402390f682443727b847e9496e4a2a \
-    --hash=sha256:8323a43bd9c91f62bb7d4be74cc9ff10090e7ef820e27bfe8815c57e68261311 \
-    --hash=sha256:8529b07b49b2d89d6917cfa157d3ea1dfb4d319d51e23030664a827fe5fd2131 \
-    --hash=sha256:87fa943e8bbe40c8c1ba4086971a6fefbf75e9991217c55ed1bcb2f1985bd3d4 \
-    --hash=sha256:88236b90dda77f0394f878324cfbae05ae6fde8a84d548cfe73a75278d760291 \
-    --hash=sha256:891c353e95bb11abb548ca95c8b98050f3620a7378332eb90d6acdef35b401d4 \
-    --hash=sha256:89ba3d548ee1e6291a20f3c7380c92f71e358ce8b9e48161401e087e0bc740f8 \
-    --hash=sha256:8c6be72eac3c14baa473620e04f74186c5d8f45d80f8f2b4eda6e1d18af808e8 \
-    --hash=sha256:9a242871b3d8eecc56d350e5e03ea1854de47b17f040446da0e47dc3e0b9ad4d \
-    --hash=sha256:9a3ff5fb015f6feb78340143584d9f8a0b91b6293d6b5cf4295b3e95d179b88c \
-    --hash=sha256:9a5a544861b21e0e7575b6023adebe7a8c6321127bb1d238eb40d99803a0e8bd \
-    --hash=sha256:9d57677238a0c5411c76097b8b93bdebb02eb845814c90f0b01727527a179e4d \
-    --hash=sha256:9d8c68c4145041b4eeae96239802cfdfd9ef927754a5be3f50505f09f309d8c6 \
-    --hash=sha256:9d9fcd06c952efa4b6b95f3d788a819b7f33d11bea377be6b8980c95e7d10775 \
-    --hash=sha256:a0057b5435a65b933cbf5d859cd4956624df37b8bf0917c71756e4b3d9958b9e \
-    --hash=sha256:a65bffd24409454b889af33b6c49d0d9bcd1a219b972fba975ac935f17bdf627 \
-    --hash=sha256:b0ed6ad6c9640671689c2dbe6244680fe8b897c08fd1fab2228429b66c518e5e \
-    --hash=sha256:b21650fa6907e523869e0396c5bd591cc326e5c1dd594dcdccac089561cacfb8 \
-    --hash=sha256:b3f7e671fb19734c872566e57ce7fc235fa953d7c181bb4ef138e17d607dc8a1 \
-    --hash=sha256:b77159d9862374da213f741af0c361720200ab7ad21b9f12556e0eb95912cd48 \
-    --hash=sha256:bb36fbb48b22985d13a6b496ea5fb9bb2a076fea943831643836c9f6febbcfdc \
-    --hash=sha256:d066ffc5ed0be00cd0352c95800a519cf9e4b5dd34a028d301bdc7177c72daf3 \
-    --hash=sha256:d332eecf307fca852d02b63f35a7872de32d5ba8b4ec32da82f45df986b39ff6 \
-    --hash=sha256:d808a5a5411982a09fef6b49aac62986274ab050e9d3e9817ad65b2791ed1425 \
-    --hash=sha256:d9bdfa74d369256e4218000a629978590fd7cb6cf6893251dad13d051090436d \
-    --hash=sha256:db6a0ddc1282ceb9032e41853e659c9b638789be38e5b8ad7498caac00231c23 \
-    --hash=sha256:debaf04f813ada978d7d16c7dfa16f3c9c2ec9adf4656efdc4defdf841fc2f0c \
-    --hash=sha256:f0408e2dbad9e82b4c960274214af533f856a199c9274bd4aff55d4634dedc33 \
-    --hash=sha256:f2f3bc7cd9c9fcd39143f11342eb5963317bd54ecc98e3650ca22704b69d9653
+wrapt==1.14.1 \
+    --hash=sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3 \
+    --hash=sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b \
+    --hash=sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4 \
+    --hash=sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2 \
+    --hash=sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656 \
+    --hash=sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3 \
+    --hash=sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff \
+    --hash=sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310 \
+    --hash=sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a \
+    --hash=sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57 \
+    --hash=sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069 \
+    --hash=sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383 \
+    --hash=sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe \
+    --hash=sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87 \
+    --hash=sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d \
+    --hash=sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b \
+    --hash=sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907 \
+    --hash=sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f \
+    --hash=sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0 \
+    --hash=sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28 \
+    --hash=sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1 \
+    --hash=sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853 \
+    --hash=sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc \
+    --hash=sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3 \
+    --hash=sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3 \
+    --hash=sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164 \
+    --hash=sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1 \
+    --hash=sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c \
+    --hash=sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1 \
+    --hash=sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7 \
+    --hash=sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1 \
+    --hash=sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320 \
+    --hash=sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed \
+    --hash=sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1 \
+    --hash=sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248 \
+    --hash=sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c \
+    --hash=sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456 \
+    --hash=sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77 \
+    --hash=sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef \
+    --hash=sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1 \
+    --hash=sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7 \
+    --hash=sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86 \
+    --hash=sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4 \
+    --hash=sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d \
+    --hash=sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d \
+    --hash=sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8 \
+    --hash=sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5 \
+    --hash=sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471 \
+    --hash=sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00 \
+    --hash=sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68 \
+    --hash=sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3 \
+    --hash=sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d \
+    --hash=sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735 \
+    --hash=sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d \
+    --hash=sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569 \
+    --hash=sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7 \
+    --hash=sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59 \
+    --hash=sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5 \
+    --hash=sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb \
+    --hash=sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b \
+    --hash=sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f \
+    --hash=sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462 \
+    --hash=sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015 \
+    --hash=sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af
     # via
     #   astroid
     #   deprecated
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 61f6e830..de1f3848 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -9,9 +9,9 @@ aenum==3.1.8 \
     --hash=sha256:859fe994719e6b5e39f15f73acd84e08b4e57dc642373b177a5fa6646798706a \
     --hash=sha256:8dbe15f446eb8264b788dfeca163fb0a043d408d212152397dc11377b851e4ae
     # via grizzly-loadtester (pyproject.toml)
-astroid==2.11.2 \
-    --hash=sha256:8d0a30fe6481ce919f56690076eafbb2fb649142a89dc874f1ec0e7a011492d0 \
-    --hash=sha256:cc8cc0d2d916c42d0a7c476c57550a4557a083081976bf42a73414322a6411d9
+astroid==2.11.4 \
+    --hash=sha256:561dc6015eecce7e696ff7e3b40434bc56831afeff783f0ea853e19c4f635c06 \
+    --hash=sha256:da0632b7c046d8361dfe1b1abb2e085a38624961fabe2997565a9c06c1be9d9a
     # via pylint
 astunparse==1.6.3 \
     --hash=sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872 \
@@ -38,9 +38,9 @@ azure-core==1.22.1 \
     #   azure-storage-blob
     #   grizzly-loadtester (pyproject.toml)
     #   opencensus-ext-azure
-azure-identity==1.9.0 \
-    --hash=sha256:0854d19da4c5644641814dc4f951c42e01400b5792f09dfb6bffa726d8b9160d \
-    --hash=sha256:2e75bbf0a72309b8f95f6769214b90bf271f3662d28d962bcddf4d2406157b51
+azure-identity==1.10.0 \
+    --hash=sha256:656e5034d9cef297cf9b35376ed620085273c18cfa52cea4a625bf0d5d2d6409 \
+    --hash=sha256:b386f1ccbea6a48b9ab7e7f162adc456793c345193a7c1a713959562b08dcbbd
     # via opencensus-ext-azure
 azure-servicebus==7.6.1 \
     --hash=sha256:c2ab7eb962959253f603189037d3deb8e1548b1b59b8150852137e599058ac12 \
@@ -68,22 +68,25 @@ backports-zoneinfo==0.2.1 ; python_version < "3.9" \
     --hash=sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac \
     --hash=sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2
     # via grizzly-loadtester (pyproject.toml)
-bcrypt==3.2.0 \
-    --hash=sha256:56e5da069a76470679f312a7d3d23deb3ac4519991a0361abc11da837087b61d \
-    --hash=sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29 \
-    --hash=sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7 \
-    --hash=sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34 \
-    --hash=sha256:a0584a92329210fcd75eb8a3250c5a941633f8bfaf2a18f81009b097732839b7 \
-    --hash=sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55 \
-    --hash=sha256:b589229207630484aefe5899122fb938a5b017b0f4349f769b8c13e78d99a8fd \
-    --hash=sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6 \
-    --hash=sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1 \
-    --hash=sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d
+bcrypt==3.2.2 \
+    --hash=sha256:2b02d6bfc6336d1094276f3f588aa1225a598e27f8e3388f4db9948cb707b521 \
+    --hash=sha256:433c410c2177057705da2a9f2cd01dd157493b2a7ac14c8593a16b3dab6b6bfb \
+    --hash=sha256:4e029cef560967fb0cf4a802bcf4d562d3d6b4b1bf81de5ec1abbe0f1adb027e \
+    --hash=sha256:61bae49580dce88095d669226d5076d0b9d927754cedbdf76c6c9f5099ad6f26 \
+    --hash=sha256:6d2cb9d969bfca5bc08e45864137276e4c3d3d7de2b162171def3d188bf9d34a \
+    --hash=sha256:7180d98a96f00b1050e93f5b0f556e658605dd9f524d0b0e68ae7944673f525e \
+    --hash=sha256:7d9ba2e41e330d2af4af6b1b6ec9e6128e91343d0b4afb9282e54e5508f31baa \
+    --hash=sha256:7ff2069240c6bbe49109fe84ca80508773a904f5a8cb960e02a977f7f519b129 \
+    --hash=sha256:88273d806ab3a50d06bc6a2fc7c87d737dd669b76ad955f449c43095389bc8fb \
+    --hash=sha256:a2c46100e315c3a5b90fdc53e429c006c5f962529bc27e1dfd656292c20ccc40 \
+    --hash=sha256:cd43303d6b8a165c29ec6756afd169faba9396a9472cdff753fe9f19b96ce2fa
     # via paramiko
 behave==1.2.6 \
     --hash=sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86 \
     --hash=sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c
-    # via grizzly-loadtester (pyproject.toml)
+    # via
+    #   grizzly-loadtester (pyproject.toml)
+    #   grizzly-loadtester-cli
 brotli==1.0.9 \
     --hash=sha256:12effe280b8ebfd389022aa65114e30407540ccb89b177d3fbc9a4f177c4bd5d \
     --hash=sha256:160c78292e98d21e73a4cc7f76a234390e516afcd982fa17e1422f7c6a9ce9c8 \
@@ -219,9 +222,9 @@ charset-normalizer==2.0.12 \
     --hash=sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597 \
     --hash=sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df
     # via requests
-click==8.1.2 \
-    --hash=sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e \
-    --hash=sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72
+click==8.1.3 \
+    --hash=sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e \
+    --hash=sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48
     # via
     #   flask
     #   mkdocs
@@ -297,6 +300,7 @@ cryptography==36.0.2 \
     # via
     #   azure-identity
     #   azure-storage-blob
+    #   grizzly-loadtester (pyproject.toml)
     #   msal
     #   paramiko
     #   pyjwt
@@ -348,9 +352,9 @@ flake8==4.0.1 \
     --hash=sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d \
     --hash=sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d
     # via grizzly-loadtester (pyproject.toml)
-flask==2.1.1 \
-    --hash=sha256:8a4cf32d904cf5621db9f0c9fbcd7efabf3003f22a04e4d0ce790c7137ec5264 \
-    --hash=sha256:a8c9bd3e558ec99646d177a9739c41df1ded0629480b4c8d2975412f3c9519c8
+flask==2.1.2 \
+    --hash=sha256:315ded2ddf8a6281567edb27393010fe3406188bafbfe65a3339d5787d89e477 \
+    --hash=sha256:fad5b446feb0d6db6aec0c3184d16a8c1f6c3e464b511649c8918a9be100b4fe
     # via
     #   flask-basicauth
     #   flask-cors
@@ -443,17 +447,17 @@ geventhttpclient==1.5.3 \
     --hash=sha256:fa290a202446630cc71593712bea548f296b89cb8a4161002c2e03afc3b2ffed \
     --hash=sha256:fbeb1ed7228fc406229c8693f8a02b8e064f7a9979665ac0181c983667cc383c
     # via locust
-ghp-import==2.0.2 \
-    --hash=sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46 \
-    --hash=sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071
+ghp-import==2.1.0 \
+    --hash=sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619 \
+    --hash=sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343
     # via mkdocs
-google-api-core==2.7.2 \
-    --hash=sha256:65480309a7437f739e4476da037af02a3ec8263f1d1f89f72bbdc8f54fe402d2 \
-    --hash=sha256:8fcbe52dc129fd83dca4e638a76f22b3a11579c493e643134e50e9870b233302
+google-api-core==2.7.3 \
+    --hash=sha256:17957f0704cbe95bd2ce25019efd2046423978594d181d4263e5dcffd2dbbc79 \
+    --hash=sha256:bb40d2d6412c77c367943b88d08323091d724deaea96892fff4a1ebd06f6e5be
     # via opencensus
-google-auth==2.6.5 \
-    --hash=sha256:04e224f241c0566477bb35a8a93be8c635210de743bde454d49393cfb605266d \
-    --hash=sha256:9a88ee548f6fd49467e2e443dfbfe10344e5a270629a137a3a0b3437ec6b02a6
+google-auth==2.6.6 \
+    --hash=sha256:1ba4938e032b73deb51e59c4656a00e0939cf0b1112575099f136babb4563312 \
+    --hash=sha256:349ac49b18b01019453cc99c11c92ed772739778c92f184002b7ab3a5b7ac77d
     # via google-api-core
 googleapis-common-protos==1.56.0 \
     --hash=sha256:4007500795bcfc269d279f0f7d253ae18d6dc1ff5d5a73613ffe452038b1ec5f \
@@ -516,6 +520,10 @@ greenlet==1.1.2 \
     --hash=sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c \
     --hash=sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b
     # via gevent
+grizzly-loadtester-cli==3.0.1 \
+    --hash=sha256:779ee76f6e8cdb0de98596d2af8791b71535c288b990198c3a76ed941ca76ea2 \
+    --hash=sha256:870ada30fe5b50549a0e573036b0da363e03be5f9d5238d49275b902f3d1d7d4
+    # via grizzly-loadtester (pyproject.toml)
 idna==3.3 \
     --hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \
     --hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d
@@ -555,6 +563,7 @@ jinja2==3.0.3 \
     # via
     #   flask
     #   grizzly-loadtester (pyproject.toml)
+    #   grizzly-loadtester-cli
     #   locust
     #   mkdocs
     #   mkdocs-material
@@ -735,9 +744,9 @@ mkdocs==1.3.0 \
     # via
     #   grizzly-loadtester (pyproject.toml)
     #   mkdocs-material
-mkdocs-material==8.2.9 \
-    --hash=sha256:c177ff180b024bc061714c9483a8d26d36e1b9fdef4be8e70e243770416fe9d7 \
-    --hash=sha256:fbe39baa57c70fdbe9d1a24c6c2d0625e255e74f22b20aff43abb64157446f4d
+mkdocs-material==8.2.13 \
+    --hash=sha256:2666f1d7d6a8dc28dda1e777f77add12799e66bd00250de99914a33525763816 \
+    --hash=sha256:505408fe001d668543236f5db5a88771460ad83ef7b58826630cc1f8b7e63099
     # via grizzly-loadtester (pyproject.toml)
 mkdocs-material-extensions==1.0.3 \
     --hash=sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44 \
@@ -749,9 +758,9 @@ msal==1.17.0 \
     # via
     #   azure-identity
     #   msal-extensions
-msal-extensions==0.3.1 \
-    --hash=sha256:89df9c0237e1adf16938fa58575db59c2bb9de04a83ffb0452c8dfc79031f717 \
-    --hash=sha256:d9029af70f2cbdc5ad7ecfed61cb432ebe900484843ccf72825445dbfe62d311
+msal-extensions==1.0.0 \
+    --hash=sha256:91e3db9620b822d0ed2b4d1850056a0f133cba04455e62f11612e40f5502f2ee \
+    --hash=sha256:c676aba56b0cce3783de1b5c5ecfe828db998167875126ca4b47dc6436451354
     # via azure-identity
 msgpack==1.0.3 \
     --hash=sha256:0d8c332f53ffff01953ad25131272506500b14750c1d0ce8614b17d098252fbc \
@@ -797,30 +806,30 @@ msrest==0.6.21 \
     # via
     #   azure-servicebus
     #   azure-storage-blob
-mypy==0.942 \
-    --hash=sha256:0e2dd88410937423fba18e57147dd07cd8381291b93d5b1984626f173a26543e \
-    --hash=sha256:10daab80bc40f84e3f087d896cdb53dc811a9f04eae4b3f95779c26edee89d16 \
-    --hash=sha256:17e44649fec92e9f82102b48a3bf7b4a5510ad0cd22fa21a104826b5db4903e2 \
-    --hash=sha256:1a0459c333f00e6a11cbf6b468b870c2b99a906cb72d6eadf3d1d95d38c9352c \
-    --hash=sha256:246e1aa127d5b78488a4a0594bd95f6d6fb9d63cf08a66dafbff8595d8891f67 \
-    --hash=sha256:2b184db8c618c43c3a31b32ff00cd28195d39e9c24e7c3b401f3db7f6e5767f5 \
-    --hash=sha256:2bc249409a7168d37c658e062e1ab5173300984a2dada2589638568ddc1db02b \
-    --hash=sha256:3841b5433ff936bff2f4dc8d54cf2cdbfea5d8e88cedfac45c161368e5770ba6 \
-    --hash=sha256:4c3e497588afccfa4334a9986b56f703e75793133c4be3a02d06a3df16b67a58 \
-    --hash=sha256:5bf44840fb43ac4074636fd47ee476d73f0039f4f54e86d7265077dc199be24d \
-    --hash=sha256:64235137edc16bee6f095aba73be5334677d6f6bdb7fa03cfab90164fa294a17 \
-    --hash=sha256:6776e5fa22381cc761df53e7496a805801c1a751b27b99a9ff2f0ca848c7eca0 \
-    --hash=sha256:6ce34a118d1a898f47def970a2042b8af6bdcc01546454726c7dd2171aa6dfca \
-    --hash=sha256:6f6ad963172152e112b87cc7ec103ba0f2db2f1cd8997237827c052a3903eaa6 \
-    --hash=sha256:6f7106cbf9cc2f403693bf50ed7c9fa5bb3dfa9007b240db3c910929abe2a322 \
-    --hash=sha256:7742d2c4e46bb5017b51c810283a6a389296cda03df805a4f7869a6f41246534 \
-    --hash=sha256:9521c1265ccaaa1791d2c13582f06facf815f426cd8b07c3a485f486a8ffc1f3 \
-    --hash=sha256:a1b383fe99678d7402754fe90448d4037f9512ce70c21f8aee3b8bf48ffc51db \
-    --hash=sha256:b840cfe89c4ab6386c40300689cd8645fc8d2d5f20101c7f8bd23d15fca14904 \
-    --hash=sha256:d8d3ba77e56b84cd47a8ee45b62c84b6d80d32383928fe2548c9a124ea0a725c \
-    --hash=sha256:dcd955f36e0180258a96f880348fbca54ce092b40fbb4b37372ae3b25a0b0a46 \
-    --hash=sha256:e865fec858d75b78b4d63266c9aff770ecb6a39dfb6d6b56c47f7f8aba6baba8 \
-    --hash=sha256:edf7237137a1a9330046dbb14796963d734dd740a98d5e144a3eb1d267f5f9ee
+mypy==0.950 \
+    --hash=sha256:0112752a6ff07230f9ec2f71b0d3d4e088a910fdce454fdb6553e83ed0eced7d \
+    --hash=sha256:0384d9f3af49837baa92f559d3fa673e6d2652a16550a9ee07fc08c736f5e6f8 \
+    --hash=sha256:1b333cfbca1762ff15808a0ef4f71b5d3eed8528b23ea1c3fb50543c867d68de \
+    --hash=sha256:1fdeb0a0f64f2a874a4c1f5271f06e40e1e9779bf55f9567f149466fc7a55038 \
+    --hash=sha256:4c653e4846f287051599ed8f4b3c044b80e540e88feec76b11044ddc5612ffed \
+    --hash=sha256:563514c7dc504698fb66bb1cf897657a173a496406f1866afae73ab5b3cdb334 \
+    --hash=sha256:5b231afd6a6e951381b9ef09a1223b1feabe13625388db48a8690f8daa9b71ff \
+    --hash=sha256:5ce6a09042b6da16d773d2110e44f169683d8cc8687e79ec6d1181a72cb028d2 \
+    --hash=sha256:5e7647df0f8fc947388e6251d728189cfadb3b1e558407f93254e35abc026e22 \
+    --hash=sha256:6003de687c13196e8a1243a5e4bcce617d79b88f83ee6625437e335d89dfebe2 \
+    --hash=sha256:61504b9a5ae166ba5ecfed9e93357fd51aa693d3d434b582a925338a2ff57fd2 \
+    --hash=sha256:77423570c04aca807508a492037abbd72b12a1fb25a385847d191cd50b2c9605 \
+    --hash=sha256:a4d9898f46446bfb6405383b57b96737dcfd0a7f25b748e78ef3e8c576bba3cb \
+    --hash=sha256:a952b8bc0ae278fc6316e6384f67bb9a396eb30aced6ad034d3a76120ebcc519 \
+    --hash=sha256:b5b5bd0ffb11b4aba2bb6d31b8643902c48f990cc92fda4e21afac658044f0c0 \
+    --hash=sha256:ca75ecf2783395ca3016a5e455cb322ba26b6d33b4b413fcdedfc632e67941dc \
+    --hash=sha256:cf9c261958a769a3bd38c3e133801ebcd284ffb734ea12d01457cb09eacf7d7b \
+    --hash=sha256:dd4d670eee9610bf61c25c940e9ade2d0ed05eb44227275cce88701fee014b1f \
+    --hash=sha256:e19736af56947addedce4674c0971e5dceef1b5ec7d667fe86bcd2b07f8f9075 \
+    --hash=sha256:eaea21d150fb26d7b4856766e7addcf929119dd19fc832b22e71d942835201ef \
+    --hash=sha256:eaff8156016487c1af5ffa5304c3e3fd183edcb412f3e9c72db349faf3f6e0eb \
+    --hash=sha256:ee0a36edd332ed2c5208565ae6e3a7afc0eabb53f5327e281f2ef03a6bc7687a \
+    --hash=sha256:ef7beb2a3582eb7a9f37beaf38a28acfd801988cde688760aea9e6cc4832b10b
     # via grizzly-loadtester (pyproject.toml)
 mypy-extensions==0.4.3 \
     --hash=sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d \
@@ -828,9 +837,9 @@ mypy-extensions==0.4.3 \
     # via
     #   grizzly-loadtester (pyproject.toml)
     #   mypy
-nr-util==0.8.10 \
-    --hash=sha256:2a7c470aac00248d0f9b00c5fd8af40e22b59ed6e79a2a64b628648c0cc7b569 \
-    --hash=sha256:48a1d5af51f7e1e86cd3534cd464519edc9f76ec1cc6c1a2cead795bc8b6bfc9
+nr-util==0.8.11 \
+    --hash=sha256:2513458a7bffca4b67b0c19aabf09792d2062faed59c214b54e3b17d3c50c4c9 \
+    --hash=sha256:fc4562d41cf1276d469cbd2797d02886e52133f12db1145ac9a209a44181c601
     # via
     #   databind-core
     #   databind-json
@@ -840,27 +849,28 @@ oauthlib==3.2.0 \
     --hash=sha256:23a8208d75b902797ea29fd31fa80a15ed9dc2c6c16fe73f5d346f83f6fa27a2 \
     --hash=sha256:6db33440354787f9b7f3a6dbd4febf5d0f93758354060e802f6c06cb493022fe
     # via requests-oauthlib
-opencensus==0.8.0 \
-    --hash=sha256:476d99fb82f3807acf566e6c232049dc13e825102be104e6231784abcc1b1a5e \
-    --hash=sha256:7a60bdbceb370a379bccad9ea9d1d56fcce650cf12a6d2b129e6880d2632eeab
+opencensus==0.9.0 \
+    --hash=sha256:49282040bee3c9e83a55f96c30faecb6cd4b47916edceb1c180b04a6b66b6c4c \
+    --hash=sha256:6af715a27d38775b4710c53eb611614b9761cc118c3906b0074ea85ce6a673f9
     # via opencensus-ext-azure
 opencensus-context==0.1.2 \
     --hash=sha256:8bfb461d18f1dd243420224603c71544d7ac888bb236cf0d0a58545942561478 \
     --hash=sha256:8cf64ce901e6b35f491bd1521e6541676e034c52d00c95500e9364600f835c06
     # via opencensus
-opencensus-ext-azure==1.1.3 \
-    --hash=sha256:70ad2601ef24e655d1fd5e6e53f57599f919ca2b943c5371c8a794da379a5fe8 \
-    --hash=sha256:fba89a747dfee7ddc1a23875ba0b7e77f5ae73540336079901ffcd7d6141d5dc
+opencensus-ext-azure==1.1.4 \
+    --hash=sha256:6945413848c04cc72b214d7ddf083115a00e3b54e8d963d0efe71ccb9a3f3cdc \
+    --hash=sha256:7560b133674cd74b88ed583655c4c441e672b07315b81c4986a76bde0a517f64
     # via grizzly-loadtester (pyproject.toml)
 packaging==21.3 \
     --hash=sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb \
     --hash=sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522
     # via
+    #   grizzly-loadtester-cli
     #   mkdocs
     #   pytest
-paramiko==2.10.3 \
-    --hash=sha256:ac6593479f2b47a9422eca076b22cff9f795495e6733a64723efc75dd8c92101 \
-    --hash=sha256:ddb1977853aef82804b35d72a0e597b244fa326c404c350bd00c5b01dbfee71a
+paramiko==2.10.4 \
+    --hash=sha256:3c9ed6084f4b671ab66dc3c729092d32d96c3258f1426071301cb33654b09027 \
+    --hash=sha256:3d2e650b6812ce6d160abff701d6ef4434ec97934b13e95cf1ad3da70ffb5c58
     # via grizzly-loadtester (pyproject.toml)
 parse==1.19.0 \
     --hash=sha256:9ff82852bcb65d139813e2a5197627a94966245c897796760a3a2a8eb66f020b
@@ -887,31 +897,31 @@ portalocker==2.4.0 \
     --hash=sha256:a648ad761b8ea27370cb5915350122cd807b820d2193ed5c9cc28f163df637f4 \
     --hash=sha256:b092f48e1e30a234ab3dd1cfd44f2f235e8a41f4e310e463fc8d6798d1c3c235
     # via msal-extensions
-protobuf==3.20.0 \
-    --hash=sha256:001c2160c03b6349c04de39cf1a58e342750da3632f6978a1634a3dcca1ec10e \
-    --hash=sha256:0b250c60256c8824219352dc2a228a6b49987e5bf94d3ffcf4c46585efcbd499 \
-    --hash=sha256:1d24c81c2310f0063b8fc1c20c8ed01f3331be9374b4b5c2de846f69e11e21fb \
-    --hash=sha256:1eb13f5a5a59ca4973bcfa2fc8fff644bd39f2109c3f7a60bd5860cb6a49b679 \
-    --hash=sha256:25d2fcd6eef340082718ec9ad2c58d734429f2b1f7335d989523852f2bba220b \
-    --hash=sha256:32bf4a90c207a0b4e70ca6dd09d43de3cb9898f7d5b69c2e9e3b966a7f342820 \
-    --hash=sha256:38fd9eb74b852e4ee14b16e9670cd401d147ee3f3ec0d4f7652e0c921d6227f8 \
-    --hash=sha256:47257d932de14a7b6c4ae1b7dbf592388153ee35ec7cae216b87ae6490ed39a3 \
-    --hash=sha256:4eda68bd9e2a4879385e6b1ea528c976f59cd9728382005cc54c28bcce8db983 \
-    --hash=sha256:52bae32a147c375522ce09bd6af4d2949aca32a0415bc62df1456b3ad17c6001 \
-    --hash=sha256:542f25a4adf3691a306dcc00bf9a73176554938ec9b98f20f929a044f80acf1b \
-    --hash=sha256:5b5860b790498f233cdc8d635a17fc08de62e59d4dcd8cdb6c6c0d38a31edf2b \
-    --hash=sha256:6efe066a7135233f97ce51a1aa007d4fb0be28ef093b4f88dac4ad1b3a2b7b6f \
-    --hash=sha256:71b2c3d1cd26ed1ec7c8196834143258b2ad7f444efff26fdc366c6f5e752702 \
-    --hash=sha256:7a53d4035427b9dbfbb397f46642754d294f131e93c661d056366f2a31438263 \
-    --hash=sha256:7dcd84dc31ebb35ade755e06d1561d1bd3b85e85dbdbf6278011fc97b22810db \
-    --hash=sha256:88c8be0558bdfc35e68c42ae5bf785eb9390d25915d4863bbc7583d23da77074 \
-    --hash=sha256:8be43a91ab66fe995e85ccdbdd1046d9f0443d59e060c0840319290de25b7d33 \
-    --hash=sha256:8d84453422312f8275455d1cb52d850d6a4d7d714b784e41b573c6f5bfc2a029 \
-    --hash=sha256:9d0f3aca8ca51c8b5e204ab92bd8afdb2a8e3df46bd0ce0bd39065d79aabcaa4 \
-    --hash=sha256:a1eebb6eb0653e594cb86cd8e536b9b083373fca9aba761ade6cd412d46fb2ab \
-    --hash=sha256:bc14037281db66aa60856cd4ce4541a942040686d290e3f3224dd3978f88f554 \
-    --hash=sha256:fbcbb068ebe67c4ff6483d2e2aa87079c325f8470b24b098d6bf7d4d21d57a69 \
-    --hash=sha256:fd7133b885e356fa4920ead8289bb45dc6f185a164e99e10279f33732ed5ce15
+protobuf==3.20.1 \
+    --hash=sha256:06059eb6953ff01e56a25cd02cca1a9649a75a7e65397b5b9b4e929ed71d10cf \
+    --hash=sha256:097c5d8a9808302fb0da7e20edf0b8d4703274d140fd25c5edabddcde43e081f \
+    --hash=sha256:284f86a6207c897542d7e956eb243a36bb8f9564c1742b253462386e96c6b78f \
+    --hash=sha256:32ca378605b41fd180dfe4e14d3226386d8d1b002ab31c969c366549e66a2bb7 \
+    --hash=sha256:3cc797c9d15d7689ed507b165cd05913acb992d78b379f6014e013f9ecb20996 \
+    --hash=sha256:62f1b5c4cd6c5402b4e2d63804ba49a327e0c386c99b1675c8a0fefda23b2067 \
+    --hash=sha256:69ccfdf3657ba59569c64295b7d51325f91af586f8d5793b734260dfe2e94e2c \
+    --hash=sha256:6f50601512a3d23625d8a85b1638d914a0970f17920ff39cec63aaef80a93fb7 \
+    --hash=sha256:7403941f6d0992d40161aa8bb23e12575637008a5a02283a930addc0508982f9 \
+    --hash=sha256:755f3aee41354ae395e104d62119cb223339a8f3276a0cd009ffabfcdd46bb0c \
+    --hash=sha256:77053d28427a29987ca9caf7b72ccafee011257561259faba8dd308fda9a8739 \
+    --hash=sha256:7e371f10abe57cee5021797126c93479f59fccc9693dafd6bd5633ab67808a91 \
+    --hash=sha256:9016d01c91e8e625141d24ec1b20fed584703e527d28512aa8c8707f105a683c \
+    --hash=sha256:9be73ad47579abc26c12024239d3540e6b765182a91dbc88e23658ab71767153 \
+    --hash=sha256:adc31566d027f45efe3f44eeb5b1f329da43891634d61c75a5944e9be6dd42c9 \
+    --hash=sha256:adfc6cf69c7f8c50fd24c793964eef18f0ac321315439d94945820612849c388 \
+    --hash=sha256:af0ebadc74e281a517141daad9d0f2c5d93ab78e9d455113719a45a49da9db4e \
+    --hash=sha256:cb29edb9eab15742d791e1025dd7b6a8f6fcb53802ad2f6e3adcb102051063ab \
+    --hash=sha256:cd68be2559e2a3b84f517fb029ee611546f7812b1fdd0aa2ecc9bc6ec0e4fdde \
+    --hash=sha256:cdee09140e1cd184ba9324ec1df410e7147242b94b5f8b0c64fc89e38a8ba531 \
+    --hash=sha256:db977c4ca738dd9ce508557d4fce0f5aebd105e158c725beec86feb1f6bc20d8 \
+    --hash=sha256:dd5789b2948ca702c17027c84c2accb552fc30f4622a98ab5c51fcfe8c50d3e7 \
+    --hash=sha256:e250a42f15bf9d5b09fe1b293bdba2801cd520a9f5ea2d7fb7536d4441811d20 \
+    --hash=sha256:ff8d8fa42675249bb456f5db06c00de6c2f4c27a065955917b28c4f15978b9c3
     # via
     #   google-api-core
     #   googleapis-common-protos
@@ -981,21 +991,21 @@ pyflakes==2.4.0 \
     --hash=sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c \
     --hash=sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e
     # via flake8
-pygments==2.11.2 \
-    --hash=sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65 \
-    --hash=sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a
+pygments==2.12.0 \
+    --hash=sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb \
+    --hash=sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519
     # via mkdocs-material
 pyjwt[crypto]==2.3.0 \
     --hash=sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41 \
     --hash=sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f
     # via msal
-pylint==2.13.5 \
-    --hash=sha256:c149694cfdeaee1aa2465e6eaab84c87a881a7d55e6e93e09466be7164764d1e \
-    --hash=sha256:dab221658368c7a05242e673c275c488670144123f4bd262b2777249c1c0de9b
+pylint==2.13.8 \
+    --hash=sha256:ced8968c3b699df0615e2a709554dec3ddac2f5cd06efadb69554a69eeca364a \
+    --hash=sha256:f87e863a0b08f64b5230e7e779bcb75276346995737b2c0dc2793070487b1ff6
     # via grizzly-loadtester (pyproject.toml)
-pymdown-extensions==9.3 \
-    --hash=sha256:a80553b243d3ed2d6c27723bcd64ca9887e560e6f4808baa96f36e93061eaf90 \
-    --hash=sha256:b37461a181c1c8103cfe1660081726a0361a8294cbfda88e5b02cefe976f0546
+pymdown-extensions==9.4 \
+    --hash=sha256:1baa22a60550f731630474cad28feb0405c8101f1a7ddc3ec0ed86ee510bcc43 \
+    --hash=sha256:5b7432456bf555ce2b0ab3c2439401084cda8110f24f6b3ecef952b8313dfa1b
     # via mkdocs-material
 pymqi==1.12.0 \
     --hash=sha256:ca18659242f34ff3484b454fd872036a87b285c214e0907353c815e12e46cb75
@@ -1016,9 +1026,9 @@ pyparsing==3.0.8 \
     --hash=sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954 \
     --hash=sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06
     # via packaging
-pytest==7.1.1 \
-    --hash=sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63 \
-    --hash=sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea
+pytest==7.1.2 \
+    --hash=sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c \
+    --hash=sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45
     # via
     #   grizzly-loadtester (pyproject.toml)
     #   pytest-cov
@@ -1143,6 +1153,7 @@ requests==2.27.1 \
     # via
     #   azure-core
     #   google-api-core
+    #   grizzly-loadtester-cli
     #   influxdb
     #   locust
     #   msal
@@ -1242,7 +1253,6 @@ six==1.16.0 \
     #   azure-core
     #   azure-identity
     #   azure-servicebus
-    #   bcrypt
     #   behave
     #   flask-cors
     #   geventhttpclient
@@ -1271,9 +1281,9 @@ types-backports==0.1.3 \
     --hash=sha256:dafcd61848081503e738a7768872d1dd6c018401b4d2a1cfb608ea87ec9864b9 \
     --hash=sha256:f4b7206c073df88d6200891e3d27506185fd60cda66fb289737b2fa92c0010cf
     # via grizzly-loadtester (pyproject.toml)
-types-cryptography==3.3.20 \
-    --hash=sha256:025da60274ce119e16317aa2b4822647212791b5c2e39b028571862d95fdf851 \
-    --hash=sha256:bbef5b66972596f3cd61b3091719fc935e081f3bba87ecea561bc0dbb1678753
+types-cryptography==3.3.21 \
+    --hash=sha256:ad1b9c63159c009f8676c7e41a4d595dfb96e8c03affa2e693e1617908bb409e \
+    --hash=sha256:bdeb6dd07280ac724e05f02e0d8ef01fdef729b18bb07d635d64de83171a4e70
     # via types-paramiko
 types-jinja2==2.11.9 \
     --hash=sha256:60a1e21e8296979db32f9374d8a239af4cb541ff66447bb915d8ad398f9c63b2 \
@@ -1283,25 +1293,25 @@ types-markupsafe==1.1.10 \
     --hash=sha256:85b3a872683d02aea3a5ac2a8ef590193c344092032f58457287fbf8e06711b1 \
     --hash=sha256:ca2bee0f4faafc45250602567ef38d533e877d2ddca13003b319c551ff5b3cc5
     # via types-jinja2
-types-paramiko==2.8.19 \
-    --hash=sha256:276c9ad551313e87ba2c4f0997e3ad2e31cbdd3e4abf10247d43cd973963900e \
-    --hash=sha256:52192d843664ef7c1f87b9fe0697bbbb5a8f812e43016944cfeabec7ddeca423
+types-paramiko==2.10.0 \
+    --hash=sha256:ab6893d5fce5ed06964d61939ed6a7168ab14952945a90995a628a5e82a5e161 \
+    --hash=sha256:f558bb26ff3b085f7282125559927ebf9e1c08410cd4aac5766addbf4d15c8bc
     # via grizzly-loadtester (pyproject.toml)
-types-python-dateutil==2.8.11 \
-    --hash=sha256:61222fa5518b14601a4e95ad4f17feea4ada904947610560336cf8921a7544a8 \
-    --hash=sha256:edc05d0b93201928ab0b5ff2b314413dd3d2b24eb12c470297ef929de77c93ef
+types-python-dateutil==2.8.14 \
+    --hash=sha256:367c1ffa1a52a4b2a534c9b30b40b01f2b7f2ea47a97fd821f6c76d1c7ae3129 \
+    --hash=sha256:6ac40f66cf23a5200bd179b78d6be52e81a7750e4f8e45d7cfaf3ddda60ff01f
     # via grizzly-loadtester (pyproject.toml)
 types-pyyaml==5.4.12 \
     --hash=sha256:3f4daa754357491625ae8c3a39c9e1b0d7cd5632bc4e1c35e7a7f75a64aa124b \
     --hash=sha256:e06083f85375a5678e4c19452ed6467ce2167b71db222313e1792cb8fc76859a
     # via grizzly-loadtester (pyproject.toml)
-types-requests==2.27.19 \
-    --hash=sha256:795e378117088d1e4bf41a2c01a153b73d6ea40aa9d7c0ac753abde84c0d3a8f \
-    --hash=sha256:c6c5384677d98f212516de50c4b2c38ef659b93008fbc5bb4b81726138bc8485
+types-requests==2.27.25 \
+    --hash=sha256:2444905c89731dbcb6bbcd6d873a04252445df7623917c640e463b2b28d2a708 \
+    --hash=sha256:805ae7e38fd9d157153066dc4381cf585fd34dfa212f2fc1fece248c05aac571
     # via grizzly-loadtester (pyproject.toml)
-types-urllib3==1.26.13 \
-    --hash=sha256:40f8fb5e8cd7d57e8aefdee3fdd5e930aa1a1bb4179cdadd55226cea588af790 \
-    --hash=sha256:ff7500641824f881b2c7bde4cc57e6c3abf03d1e005bae83aca752e77213a5da
+types-urllib3==1.26.14 \
+    --hash=sha256:2a2578e4b36341ccd240b00fccda9826988ff0589a44ba4a664bbd69ef348d27 \
+    --hash=sha256:5d2388aa76395b1e3999ff789ea5b3283677dad8e9bcf3d9117ba19271fd35d9
     # via types-requests
 typing-extensions==3.10.0.2 \
     --hash=sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e \
@@ -1380,9 +1390,9 @@ watchdog==2.1.7 \
     # via
     #   mkdocs
     #   pydoc-markdown
-werkzeug==2.1.1 \
-    --hash=sha256:3c5493ece8268fecdcdc9c0b112211acd006354723b280d643ec732b6d4063d6 \
-    --hash=sha256:f8e89a20aeabbe8a893c24a461d3ee5dad2123b05cc6abd73ceed01d39c3ae74
+werkzeug==2.1.2 \
+    --hash=sha256:1ce08e8093ed67d638d63879fd1ba3735817f7a80de3674d293f5984f25fb6e6 \
+    --hash=sha256:72a4b735692dd3135217911cbeaa1be5fa3f62bffb8745c5215420a03dc55255
     # via
     #   flask
     #   locust
@@ -1390,71 +1400,71 @@ wheel==0.37.1 \
     --hash=sha256:4bdcd7d840138086126cd09254dc6195fb4fc6f01c050a1d7236f2630db1d22a \
     --hash=sha256:e9a504e793efbca1b8e0e9cb979a249cf4a0a7b5b8c9e8b65a5e39d49529c1c4
     # via astunparse
-wrapt==1.14.0 \
-    --hash=sha256:00108411e0f34c52ce16f81f1d308a571df7784932cc7491d1e94be2ee93374b \
-    --hash=sha256:01f799def9b96a8ec1ef6b9c1bbaf2bbc859b87545efbecc4a78faea13d0e3a0 \
-    --hash=sha256:09d16ae7a13cff43660155383a2372b4aa09109c7127aa3f24c3cf99b891c330 \
-    --hash=sha256:14e7e2c5f5fca67e9a6d5f753d21f138398cad2b1159913ec9e9a67745f09ba3 \
-    --hash=sha256:167e4793dc987f77fd476862d32fa404d42b71f6a85d3b38cbce711dba5e6b68 \
-    --hash=sha256:1807054aa7b61ad8d8103b3b30c9764de2e9d0c0978e9d3fc337e4e74bf25faa \
-    --hash=sha256:1f83e9c21cd5275991076b2ba1cd35418af3504667affb4745b48937e214bafe \
-    --hash=sha256:21b1106bff6ece8cb203ef45b4f5778d7226c941c83aaaa1e1f0f4f32cc148cd \
-    --hash=sha256:22626dca56fd7f55a0733e604f1027277eb0f4f3d95ff28f15d27ac25a45f71b \
-    --hash=sha256:23f96134a3aa24cc50614920cc087e22f87439053d886e474638c68c8d15dc80 \
-    --hash=sha256:2498762814dd7dd2a1d0248eda2afbc3dd9c11537bc8200a4b21789b6df6cd38 \
-    --hash=sha256:28c659878f684365d53cf59dc9a1929ea2eecd7ac65da762be8b1ba193f7e84f \
-    --hash=sha256:2eca15d6b947cfff51ed76b2d60fd172c6ecd418ddab1c5126032d27f74bc350 \
-    --hash=sha256:354d9fc6b1e44750e2a67b4b108841f5f5ea08853453ecbf44c81fdc2e0d50bd \
-    --hash=sha256:36a76a7527df8583112b24adc01748cd51a2d14e905b337a6fefa8b96fc708fb \
-    --hash=sha256:3a0a4ca02752ced5f37498827e49c414d694ad7cf451ee850e3ff160f2bee9d3 \
-    --hash=sha256:3a71dbd792cc7a3d772ef8cd08d3048593f13d6f40a11f3427c000cf0a5b36a0 \
-    --hash=sha256:3a88254881e8a8c4784ecc9cb2249ff757fd94b911d5df9a5984961b96113fff \
-    --hash=sha256:47045ed35481e857918ae78b54891fac0c1d197f22c95778e66302668309336c \
-    --hash=sha256:4775a574e9d84e0212f5b18886cace049a42e13e12009bb0491562a48bb2b758 \
-    --hash=sha256:493da1f8b1bb8a623c16552fb4a1e164c0200447eb83d3f68b44315ead3f9036 \
-    --hash=sha256:4b847029e2d5e11fd536c9ac3136ddc3f54bc9488a75ef7d040a3900406a91eb \
-    --hash=sha256:59d7d92cee84a547d91267f0fea381c363121d70fe90b12cd88241bd9b0e1763 \
-    --hash=sha256:5a0898a640559dec00f3614ffb11d97a2666ee9a2a6bad1259c9facd01a1d4d9 \
-    --hash=sha256:5a9a1889cc01ed2ed5f34574c90745fab1dd06ec2eee663e8ebeefe363e8efd7 \
-    --hash=sha256:5b835b86bd5a1bdbe257d610eecab07bf685b1af2a7563093e0e69180c1d4af1 \
-    --hash=sha256:5f24ca7953f2643d59a9c87d6e272d8adddd4a53bb62b9208f36db408d7aafc7 \
-    --hash=sha256:61e1a064906ccba038aa3c4a5a82f6199749efbbb3cef0804ae5c37f550eded0 \
-    --hash=sha256:65bf3eb34721bf18b5a021a1ad7aa05947a1767d1aa272b725728014475ea7d5 \
-    --hash=sha256:6807bcee549a8cb2f38f73f469703a1d8d5d990815c3004f21ddb68a567385ce \
-    --hash=sha256:68aeefac31c1f73949662ba8affaf9950b9938b712fb9d428fa2a07e40ee57f8 \
-    --hash=sha256:6915682f9a9bc4cf2908e83caf5895a685da1fbd20b6d485dafb8e218a338279 \
-    --hash=sha256:6d9810d4f697d58fd66039ab959e6d37e63ab377008ef1d63904df25956c7db0 \
-    --hash=sha256:729d5e96566f44fccac6c4447ec2332636b4fe273f03da128fff8d5559782b06 \
-    --hash=sha256:748df39ed634851350efa87690c2237a678ed794fe9ede3f0d79f071ee042561 \
-    --hash=sha256:763a73ab377390e2af26042f685a26787c402390f682443727b847e9496e4a2a \
-    --hash=sha256:8323a43bd9c91f62bb7d4be74cc9ff10090e7ef820e27bfe8815c57e68261311 \
-    --hash=sha256:8529b07b49b2d89d6917cfa157d3ea1dfb4d319d51e23030664a827fe5fd2131 \
-    --hash=sha256:87fa943e8bbe40c8c1ba4086971a6fefbf75e9991217c55ed1bcb2f1985bd3d4 \
-    --hash=sha256:88236b90dda77f0394f878324cfbae05ae6fde8a84d548cfe73a75278d760291 \
-    --hash=sha256:891c353e95bb11abb548ca95c8b98050f3620a7378332eb90d6acdef35b401d4 \
-    --hash=sha256:89ba3d548ee1e6291a20f3c7380c92f71e358ce8b9e48161401e087e0bc740f8 \
-    --hash=sha256:8c6be72eac3c14baa473620e04f74186c5d8f45d80f8f2b4eda6e1d18af808e8 \
-    --hash=sha256:9a242871b3d8eecc56d350e5e03ea1854de47b17f040446da0e47dc3e0b9ad4d \
-    --hash=sha256:9a3ff5fb015f6feb78340143584d9f8a0b91b6293d6b5cf4295b3e95d179b88c \
-    --hash=sha256:9a5a544861b21e0e7575b6023adebe7a8c6321127bb1d238eb40d99803a0e8bd \
-    --hash=sha256:9d57677238a0c5411c76097b8b93bdebb02eb845814c90f0b01727527a179e4d \
-    --hash=sha256:9d8c68c4145041b4eeae96239802cfdfd9ef927754a5be3f50505f09f309d8c6 \
-    --hash=sha256:9d9fcd06c952efa4b6b95f3d788a819b7f33d11bea377be6b8980c95e7d10775 \
-    --hash=sha256:a0057b5435a65b933cbf5d859cd4956624df37b8bf0917c71756e4b3d9958b9e \
-    --hash=sha256:a65bffd24409454b889af33b6c49d0d9bcd1a219b972fba975ac935f17bdf627 \
-    --hash=sha256:b0ed6ad6c9640671689c2dbe6244680fe8b897c08fd1fab2228429b66c518e5e \
-    --hash=sha256:b21650fa6907e523869e0396c5bd591cc326e5c1dd594dcdccac089561cacfb8 \
-    --hash=sha256:b3f7e671fb19734c872566e57ce7fc235fa953d7c181bb4ef138e17d607dc8a1 \
-    --hash=sha256:b77159d9862374da213f741af0c361720200ab7ad21b9f12556e0eb95912cd48 \
-    --hash=sha256:bb36fbb48b22985d13a6b496ea5fb9bb2a076fea943831643836c9f6febbcfdc \
-    --hash=sha256:d066ffc5ed0be00cd0352c95800a519cf9e4b5dd34a028d301bdc7177c72daf3 \
-    --hash=sha256:d332eecf307fca852d02b63f35a7872de32d5ba8b4ec32da82f45df986b39ff6 \
-    --hash=sha256:d808a5a5411982a09fef6b49aac62986274ab050e9d3e9817ad65b2791ed1425 \
-    --hash=sha256:d9bdfa74d369256e4218000a629978590fd7cb6cf6893251dad13d051090436d \
-    --hash=sha256:db6a0ddc1282ceb9032e41853e659c9b638789be38e5b8ad7498caac00231c23 \
-    --hash=sha256:debaf04f813ada978d7d16c7dfa16f3c9c2ec9adf4656efdc4defdf841fc2f0c \
-    --hash=sha256:f0408e2dbad9e82b4c960274214af533f856a199c9274bd4aff55d4634dedc33 \
-    --hash=sha256:f2f3bc7cd9c9fcd39143f11342eb5963317bd54ecc98e3650ca22704b69d9653
+wrapt==1.14.1 \
+    --hash=sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3 \
+    --hash=sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b \
+    --hash=sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4 \
+    --hash=sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2 \
+    --hash=sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656 \
+    --hash=sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3 \
+    --hash=sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff \
+    --hash=sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310 \
+    --hash=sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a \
+    --hash=sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57 \
+    --hash=sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069 \
+    --hash=sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383 \
+    --hash=sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe \
+    --hash=sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87 \
+    --hash=sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d \
+    --hash=sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b \
+    --hash=sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907 \
+    --hash=sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f \
+    --hash=sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0 \
+    --hash=sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28 \
+    --hash=sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1 \
+    --hash=sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853 \
+    --hash=sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc \
+    --hash=sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3 \
+    --hash=sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3 \
+    --hash=sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164 \
+    --hash=sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1 \
+    --hash=sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c \
+    --hash=sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1 \
+    --hash=sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7 \
+    --hash=sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1 \
+    --hash=sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320 \
+    --hash=sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed \
+    --hash=sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1 \
+    --hash=sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248 \
+    --hash=sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c \
+    --hash=sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456 \
+    --hash=sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77 \
+    --hash=sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef \
+    --hash=sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1 \
+    --hash=sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7 \
+    --hash=sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86 \
+    --hash=sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4 \
+    --hash=sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d \
+    --hash=sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d \
+    --hash=sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8 \
+    --hash=sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5 \
+    --hash=sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471 \
+    --hash=sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00 \
+    --hash=sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68 \
+    --hash=sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3 \
+    --hash=sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d \
+    --hash=sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735 \
+    --hash=sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d \
+    --hash=sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569 \
+    --hash=sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7 \
+    --hash=sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59 \
+    --hash=sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5 \
+    --hash=sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb \
+    --hash=sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b \
+    --hash=sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f \
+    --hash=sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462 \
+    --hash=sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015 \
+    --hash=sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af
     # via
     #   astroid
     #   deprecated
diff --git a/requirements-script.txt b/requirements-script.txt
index ae622266..bb45c43b 100644
--- a/requirements-script.txt
+++ b/requirements-script.txt
@@ -41,9 +41,9 @@ pathvalidate==2.5.0 \
     --hash=sha256:119ba36be7e9a405d704c7b7aea4b871c757c53c9adc0ed64f40be1ed8da2781 \
     --hash=sha256:e5b2747ad557363e8f4124f0553d68878b12ecabd77bcca7e7312d5346d20262
     # via pytablewriter
-pip-licenses==3.5.3 \
-    --hash=sha256:59c148d6a03784bf945d232c0dc0e9de4272a3675acaa0361ad7712398ca86ba \
-    --hash=sha256:f44860e00957b791c6c6005a3328f2d5eaeee96ddb8e7d87d4b0aa25b02252e4
+pip-licenses==3.5.4 \
+    --hash=sha256:5e23593c670b8db616b627c68729482a65bb88498eefd8df337762fdaf7936a8 \
+    --hash=sha256:a8b4dabe2b83901f9ac876afc47b57cff9a5ebe19a6d90c0b2579fa8cf2db176
     # via -r /mnt/requirements-script.in
 ptable==0.9.2 \
     --hash=sha256:aa7fc151cb40f2dabcd2275ba6f7fd0ff8577a86be3365cd3fb297cbe09cc292
diff --git a/requirements.txt b/requirements.txt
index 41616fa3..51750fab 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -22,9 +22,9 @@ azure-core==1.22.1 \
     #   azure-storage-blob
     #   grizzly-loadtester (pyproject.toml)
     #   opencensus-ext-azure
-azure-identity==1.9.0 \
-    --hash=sha256:0854d19da4c5644641814dc4f951c42e01400b5792f09dfb6bffa726d8b9160d \
-    --hash=sha256:2e75bbf0a72309b8f95f6769214b90bf271f3662d28d962bcddf4d2406157b51
+azure-identity==1.10.0 \
+    --hash=sha256:656e5034d9cef297cf9b35376ed620085273c18cfa52cea4a625bf0d5d2d6409 \
+    --hash=sha256:b386f1ccbea6a48b9ab7e7f162adc456793c345193a7c1a713959562b08dcbbd
     # via opencensus-ext-azure
 azure-servicebus==7.6.1 \
     --hash=sha256:c2ab7eb962959253f603189037d3deb8e1548b1b59b8150852137e599058ac12 \
@@ -52,17 +52,18 @@ backports-zoneinfo==0.2.1 ; python_version < "3.9" \
     --hash=sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac \
     --hash=sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2
     # via grizzly-loadtester (pyproject.toml)
-bcrypt==3.2.0 \
-    --hash=sha256:56e5da069a76470679f312a7d3d23deb3ac4519991a0361abc11da837087b61d \
-    --hash=sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29 \
-    --hash=sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7 \
-    --hash=sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34 \
-    --hash=sha256:a0584a92329210fcd75eb8a3250c5a941633f8bfaf2a18f81009b097732839b7 \
-    --hash=sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55 \
-    --hash=sha256:b589229207630484aefe5899122fb938a5b017b0f4349f769b8c13e78d99a8fd \
-    --hash=sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6 \
-    --hash=sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1 \
-    --hash=sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d
+bcrypt==3.2.2 \
+    --hash=sha256:2b02d6bfc6336d1094276f3f588aa1225a598e27f8e3388f4db9948cb707b521 \
+    --hash=sha256:433c410c2177057705da2a9f2cd01dd157493b2a7ac14c8593a16b3dab6b6bfb \
+    --hash=sha256:4e029cef560967fb0cf4a802bcf4d562d3d6b4b1bf81de5ec1abbe0f1adb027e \
+    --hash=sha256:61bae49580dce88095d669226d5076d0b9d927754cedbdf76c6c9f5099ad6f26 \
+    --hash=sha256:6d2cb9d969bfca5bc08e45864137276e4c3d3d7de2b162171def3d188bf9d34a \
+    --hash=sha256:7180d98a96f00b1050e93f5b0f556e658605dd9f524d0b0e68ae7944673f525e \
+    --hash=sha256:7d9ba2e41e330d2af4af6b1b6ec9e6128e91343d0b4afb9282e54e5508f31baa \
+    --hash=sha256:7ff2069240c6bbe49109fe84ca80508773a904f5a8cb960e02a977f7f519b129 \
+    --hash=sha256:88273d806ab3a50d06bc6a2fc7c87d737dd669b76ad955f449c43095389bc8fb \
+    --hash=sha256:a2c46100e315c3a5b90fdc53e429c006c5f962529bc27e1dfd656292c20ccc40 \
+    --hash=sha256:cd43303d6b8a165c29ec6756afd169faba9396a9472cdff753fe9f19b96ce2fa
     # via paramiko
 behave==1.2.6 \
     --hash=sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86 \
@@ -203,9 +204,9 @@ charset-normalizer==2.0.12 \
     --hash=sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597 \
     --hash=sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df
     # via requests
-click==8.1.2 \
-    --hash=sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e \
-    --hash=sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72
+click==8.1.3 \
+    --hash=sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e \
+    --hash=sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48
     # via flask
 configargparse==1.5.3 \
     --hash=sha256:18f6535a2db9f6e02bd5626cc7455eac3e96b9ab3d969d366f9aafd5c5c00fe7 \
@@ -235,6 +236,7 @@ cryptography==36.0.2 \
     # via
     #   azure-identity
     #   azure-storage-blob
+    #   grizzly-loadtester (pyproject.toml)
     #   msal
     #   paramiko
     #   pyjwt
@@ -242,9 +244,9 @@ decorator==5.1.1 \
     --hash=sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330 \
     --hash=sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186
     # via jsonpath-ng
-flask==2.1.1 \
-    --hash=sha256:8a4cf32d904cf5621db9f0c9fbcd7efabf3003f22a04e4d0ce790c7137ec5264 \
-    --hash=sha256:a8c9bd3e558ec99646d177a9739c41df1ded0629480b4c8d2975412f3c9519c8
+flask==2.1.2 \
+    --hash=sha256:315ded2ddf8a6281567edb27393010fe3406188bafbfe65a3339d5787d89e477 \
+    --hash=sha256:fad5b446feb0d6db6aec0c3184d16a8c1f6c3e464b511649c8918a9be100b4fe
     # via
     #   flask-basicauth
     #   flask-cors
@@ -337,13 +339,13 @@ geventhttpclient==1.5.3 \
     --hash=sha256:fa290a202446630cc71593712bea548f296b89cb8a4161002c2e03afc3b2ffed \
     --hash=sha256:fbeb1ed7228fc406229c8693f8a02b8e064f7a9979665ac0181c983667cc383c
     # via locust
-google-api-core==2.7.2 \
-    --hash=sha256:65480309a7437f739e4476da037af02a3ec8263f1d1f89f72bbdc8f54fe402d2 \
-    --hash=sha256:8fcbe52dc129fd83dca4e638a76f22b3a11579c493e643134e50e9870b233302
+google-api-core==2.7.3 \
+    --hash=sha256:17957f0704cbe95bd2ce25019efd2046423978594d181d4263e5dcffd2dbbc79 \
+    --hash=sha256:bb40d2d6412c77c367943b88d08323091d724deaea96892fff4a1ebd06f6e5be
     # via opencensus
-google-auth==2.6.5 \
-    --hash=sha256:04e224f241c0566477bb35a8a93be8c635210de743bde454d49393cfb605266d \
-    --hash=sha256:9a88ee548f6fd49467e2e443dfbfe10344e5a270629a137a3a0b3437ec6b02a6
+google-auth==2.6.6 \
+    --hash=sha256:1ba4938e032b73deb51e59c4656a00e0939cf0b1112575099f136babb4563312 \
+    --hash=sha256:349ac49b18b01019453cc99c11c92ed772739778c92f184002b7ab3a5b7ac77d
     # via google-api-core
 googleapis-common-protos==1.56.0 \
     --hash=sha256:4007500795bcfc269d279f0f7d253ae18d6dc1ff5d5a73613ffe452038b1ec5f \
@@ -555,9 +557,9 @@ msal==1.17.0 \
     # via
     #   azure-identity
     #   msal-extensions
-msal-extensions==0.3.1 \
-    --hash=sha256:89df9c0237e1adf16938fa58575db59c2bb9de04a83ffb0452c8dfc79031f717 \
-    --hash=sha256:d9029af70f2cbdc5ad7ecfed61cb432ebe900484843ccf72825445dbfe62d311
+msal-extensions==1.0.0 \
+    --hash=sha256:91e3db9620b822d0ed2b4d1850056a0f133cba04455e62f11612e40f5502f2ee \
+    --hash=sha256:c676aba56b0cce3783de1b5c5ecfe828db998167875126ca4b47dc6436451354
     # via azure-identity
 msgpack==1.0.3 \
     --hash=sha256:0d8c332f53ffff01953ad25131272506500b14750c1d0ce8614b17d098252fbc \
@@ -611,21 +613,21 @@ oauthlib==3.2.0 \
     --hash=sha256:23a8208d75b902797ea29fd31fa80a15ed9dc2c6c16fe73f5d346f83f6fa27a2 \
     --hash=sha256:6db33440354787f9b7f3a6dbd4febf5d0f93758354060e802f6c06cb493022fe
     # via requests-oauthlib
-opencensus==0.8.0 \
-    --hash=sha256:476d99fb82f3807acf566e6c232049dc13e825102be104e6231784abcc1b1a5e \
-    --hash=sha256:7a60bdbceb370a379bccad9ea9d1d56fcce650cf12a6d2b129e6880d2632eeab
+opencensus==0.9.0 \
+    --hash=sha256:49282040bee3c9e83a55f96c30faecb6cd4b47916edceb1c180b04a6b66b6c4c \
+    --hash=sha256:6af715a27d38775b4710c53eb611614b9761cc118c3906b0074ea85ce6a673f9
     # via opencensus-ext-azure
 opencensus-context==0.1.2 \
     --hash=sha256:8bfb461d18f1dd243420224603c71544d7ac888bb236cf0d0a58545942561478 \
     --hash=sha256:8cf64ce901e6b35f491bd1521e6541676e034c52d00c95500e9364600f835c06
     # via opencensus
-opencensus-ext-azure==1.1.3 \
-    --hash=sha256:70ad2601ef24e655d1fd5e6e53f57599f919ca2b943c5371c8a794da379a5fe8 \
-    --hash=sha256:fba89a747dfee7ddc1a23875ba0b7e77f5ae73540336079901ffcd7d6141d5dc
+opencensus-ext-azure==1.1.4 \
+    --hash=sha256:6945413848c04cc72b214d7ddf083115a00e3b54e8d963d0efe71ccb9a3f3cdc \
+    --hash=sha256:7560b133674cd74b88ed583655c4c441e672b07315b81c4986a76bde0a517f64
     # via grizzly-loadtester (pyproject.toml)
-paramiko==2.10.3 \
-    --hash=sha256:ac6593479f2b47a9422eca076b22cff9f795495e6733a64723efc75dd8c92101 \
-    --hash=sha256:ddb1977853aef82804b35d72a0e597b244fa326c404c350bd00c5b01dbfee71a
+paramiko==2.10.4 \
+    --hash=sha256:3c9ed6084f4b671ab66dc3c729092d32d96c3258f1426071301cb33654b09027 \
+    --hash=sha256:3d2e650b6812ce6d160abff701d6ef4434ec97934b13e95cf1ad3da70ffb5c58
     # via grizzly-loadtester (pyproject.toml)
 parse==1.19.0 \
     --hash=sha256:9ff82852bcb65d139813e2a5197627a94966245c897796760a3a2a8eb66f020b
@@ -644,31 +646,31 @@ portalocker==2.4.0 \
     --hash=sha256:a648ad761b8ea27370cb5915350122cd807b820d2193ed5c9cc28f163df637f4 \
     --hash=sha256:b092f48e1e30a234ab3dd1cfd44f2f235e8a41f4e310e463fc8d6798d1c3c235
     # via msal-extensions
-protobuf==3.20.0 \
-    --hash=sha256:001c2160c03b6349c04de39cf1a58e342750da3632f6978a1634a3dcca1ec10e \
-    --hash=sha256:0b250c60256c8824219352dc2a228a6b49987e5bf94d3ffcf4c46585efcbd499 \
-    --hash=sha256:1d24c81c2310f0063b8fc1c20c8ed01f3331be9374b4b5c2de846f69e11e21fb \
-    --hash=sha256:1eb13f5a5a59ca4973bcfa2fc8fff644bd39f2109c3f7a60bd5860cb6a49b679 \
-    --hash=sha256:25d2fcd6eef340082718ec9ad2c58d734429f2b1f7335d989523852f2bba220b \
-    --hash=sha256:32bf4a90c207a0b4e70ca6dd09d43de3cb9898f7d5b69c2e9e3b966a7f342820 \
-    --hash=sha256:38fd9eb74b852e4ee14b16e9670cd401d147ee3f3ec0d4f7652e0c921d6227f8 \
-    --hash=sha256:47257d932de14a7b6c4ae1b7dbf592388153ee35ec7cae216b87ae6490ed39a3 \
-    --hash=sha256:4eda68bd9e2a4879385e6b1ea528c976f59cd9728382005cc54c28bcce8db983 \
-    --hash=sha256:52bae32a147c375522ce09bd6af4d2949aca32a0415bc62df1456b3ad17c6001 \
-    --hash=sha256:542f25a4adf3691a306dcc00bf9a73176554938ec9b98f20f929a044f80acf1b \
-    --hash=sha256:5b5860b790498f233cdc8d635a17fc08de62e59d4dcd8cdb6c6c0d38a31edf2b \
-    --hash=sha256:6efe066a7135233f97ce51a1aa007d4fb0be28ef093b4f88dac4ad1b3a2b7b6f \
-    --hash=sha256:71b2c3d1cd26ed1ec7c8196834143258b2ad7f444efff26fdc366c6f5e752702 \
-    --hash=sha256:7a53d4035427b9dbfbb397f46642754d294f131e93c661d056366f2a31438263 \
-    --hash=sha256:7dcd84dc31ebb35ade755e06d1561d1bd3b85e85dbdbf6278011fc97b22810db \
-    --hash=sha256:88c8be0558bdfc35e68c42ae5bf785eb9390d25915d4863bbc7583d23da77074 \
-    --hash=sha256:8be43a91ab66fe995e85ccdbdd1046d9f0443d59e060c0840319290de25b7d33 \
-    --hash=sha256:8d84453422312f8275455d1cb52d850d6a4d7d714b784e41b573c6f5bfc2a029 \
-    --hash=sha256:9d0f3aca8ca51c8b5e204ab92bd8afdb2a8e3df46bd0ce0bd39065d79aabcaa4 \
-    --hash=sha256:a1eebb6eb0653e594cb86cd8e536b9b083373fca9aba761ade6cd412d46fb2ab \
-    --hash=sha256:bc14037281db66aa60856cd4ce4541a942040686d290e3f3224dd3978f88f554 \
-    --hash=sha256:fbcbb068ebe67c4ff6483d2e2aa87079c325f8470b24b098d6bf7d4d21d57a69 \
-    --hash=sha256:fd7133b885e356fa4920ead8289bb45dc6f185a164e99e10279f33732ed5ce15
+protobuf==3.20.1 \
+    --hash=sha256:06059eb6953ff01e56a25cd02cca1a9649a75a7e65397b5b9b4e929ed71d10cf \
+    --hash=sha256:097c5d8a9808302fb0da7e20edf0b8d4703274d140fd25c5edabddcde43e081f \
+    --hash=sha256:284f86a6207c897542d7e956eb243a36bb8f9564c1742b253462386e96c6b78f \
+    --hash=sha256:32ca378605b41fd180dfe4e14d3226386d8d1b002ab31c969c366549e66a2bb7 \
+    --hash=sha256:3cc797c9d15d7689ed507b165cd05913acb992d78b379f6014e013f9ecb20996 \
+    --hash=sha256:62f1b5c4cd6c5402b4e2d63804ba49a327e0c386c99b1675c8a0fefda23b2067 \
+    --hash=sha256:69ccfdf3657ba59569c64295b7d51325f91af586f8d5793b734260dfe2e94e2c \
+    --hash=sha256:6f50601512a3d23625d8a85b1638d914a0970f17920ff39cec63aaef80a93fb7 \
+    --hash=sha256:7403941f6d0992d40161aa8bb23e12575637008a5a02283a930addc0508982f9 \
+    --hash=sha256:755f3aee41354ae395e104d62119cb223339a8f3276a0cd009ffabfcdd46bb0c \
+    --hash=sha256:77053d28427a29987ca9caf7b72ccafee011257561259faba8dd308fda9a8739 \
+    --hash=sha256:7e371f10abe57cee5021797126c93479f59fccc9693dafd6bd5633ab67808a91 \
+    --hash=sha256:9016d01c91e8e625141d24ec1b20fed584703e527d28512aa8c8707f105a683c \
+    --hash=sha256:9be73ad47579abc26c12024239d3540e6b765182a91dbc88e23658ab71767153 \
+    --hash=sha256:adc31566d027f45efe3f44eeb5b1f329da43891634d61c75a5944e9be6dd42c9 \
+    --hash=sha256:adfc6cf69c7f8c50fd24c793964eef18f0ac321315439d94945820612849c388 \
+    --hash=sha256:af0ebadc74e281a517141daad9d0f2c5d93ab78e9d455113719a45a49da9db4e \
+    --hash=sha256:cb29edb9eab15742d791e1025dd7b6a8f6fcb53802ad2f6e3adcb102051063ab \
+    --hash=sha256:cd68be2559e2a3b84f517fb029ee611546f7812b1fdd0aa2ecc9bc6ec0e4fdde \
+    --hash=sha256:cdee09140e1cd184ba9324ec1df410e7147242b94b5f8b0c64fc89e38a8ba531 \
+    --hash=sha256:db977c4ca738dd9ce508557d4fce0f5aebd105e158c725beec86feb1f6bc20d8 \
+    --hash=sha256:dd5789b2948ca702c17027c84c2accb552fc30f4622a98ab5c51fcfe8c50d3e7 \
+    --hash=sha256:e250a42f15bf9d5b09fe1b293bdba2801cd520a9f5ea2d7fb7536d4441811d20 \
+    --hash=sha256:ff8d8fa42675249bb456f5db06c00de6c2f4c27a065955917b28c4f15978b9c3
     # via
     #   google-api-core
     #   googleapis-common-protos
@@ -933,7 +935,6 @@ six==1.16.0 \
     #   azure-core
     #   azure-identity
     #   azure-servicebus
-    #   bcrypt
     #   behave
     #   flask-cors
     #   geventhttpclient
@@ -988,9 +989,9 @@ urllib3==1.26.9 \
     --hash=sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14 \
     --hash=sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e
     # via requests
-werkzeug==2.1.1 \
-    --hash=sha256:3c5493ece8268fecdcdc9c0b112211acd006354723b280d643ec732b6d4063d6 \
-    --hash=sha256:f8e89a20aeabbe8a893c24a461d3ee5dad2123b05cc6abd73ceed01d39c3ae74
+werkzeug==2.1.2 \
+    --hash=sha256:1ce08e8093ed67d638d63879fd1ba3735817f7a80de3674d293f5984f25fb6e6 \
+    --hash=sha256:72a4b735692dd3135217911cbeaa1be5fa3f62bffb8745c5215420a03dc55255
     # via
     #   flask
     #   locust
diff --git a/setup.cfg b/setup.cfg
index 24fe540f..8d52913f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -54,6 +54,7 @@ install_requires =
     PyYAML <6.0.0,>=5.3.0
     setproctitle >=1.2.2
     tzdata >=2022.1
+    cryptography==36.0.2
 
 [options.entry_points]
 console_scripts =
@@ -88,6 +89,7 @@ dev =
     types-requests >=2.27.0
     types-Jinja2 >=2.0.0
     types-backports >=0.1.3
+    grizzly-loadtester-cli
 ci =
     build >=0.7.0
     twine >=3.8.0,<4.0.0


Test patch:
diff --git a/grizzly/testdata/variables/csv_row.py b/grizzly/testdata/variables/csv_row.py
index 494f11cc..ed2c95df 100644
--- a/grizzly/testdata/variables/csv_row.py
+++ b/grizzly/testdata/variables/csv_row.py
@@ -23,7 +23,7 @@
 ```
 
 ```gherkin
-And value of variable "AtomicCsvRow.example" is "example.csv | random=False, repeat=True"
+And value for variable "AtomicCsvRow.example" is "example.csv | random=False, repeat=True"
 Then post request with name "authenticate" to endpoint "/api/v1/authenticate"
   """
   {
diff --git a/grizzly/testdata/variables/date.py b/grizzly/testdata/variables/date.py
index 41654985..2056203a 100644
--- a/grizzly/testdata/variables/date.py
+++ b/grizzly/testdata/variables/date.py
@@ -14,7 +14,7 @@
 ## Example
 
 ```gherkin
-And value of variable "AtomicDate.arrival" is "now | format='%Y-%m-%dT%H:%M:%S.000Z', timezone=UTC"
+And value for variable "AtomicDate.arrival" is "now | format='%Y-%m-%dT%H:%M:%S.000Z', timezone=UTC"
 ```
 
 This can then be used in a template:
diff --git a/grizzly/testdata/variables/directory_contents.py b/grizzly/testdata/variables/directory_contents.py
index f102cda3..e9180469 100644
--- a/grizzly/testdata/variables/directory_contents.py
+++ b/grizzly/testdata/variables/directory_contents.py
@@ -25,7 +25,7 @@
 ```
 
 ```gherkin
-And value of variable "AtomicDirectoryContents.files" is "files/ | repeat=True, random=False"
+And value for variable "AtomicDirectoryContents.files" is "files/ | repeat=True, random=False"
 And put request "{{ AtomicDirectoryContents.files }}" with name "put-file" to endpoint "/tmp"
 ```
 
diff --git a/grizzly/testdata/variables/integer_incrementer.py b/grizzly/testdata/variables/integer_incrementer.py
index 9a7a9ace..901815ff 100644
--- a/grizzly/testdata/variables/integer_incrementer.py
+++ b/grizzly/testdata/variables/integer_incrementer.py
@@ -13,7 +13,7 @@
 ## Example
 
 ```gherkin
-And value of variable "AtomicIntegerIncrementer.unique_id" is "100 | step=10"
+And value for variable "AtomicIntegerIncrementer.unique_id" is "100 | step=10"
 ```
 
 This can then be used in a template:
diff --git a/grizzly/testdata/variables/messagequeue.py b/grizzly/testdata/variables/messagequeue.py
index f7d26735..f975f25f 100644
--- a/grizzly/testdata/variables/messagequeue.py
+++ b/grizzly/testdata/variables/messagequeue.py
@@ -45,7 +45,7 @@
 ## Example
 
 ```gherkin
-And value of variable "AtomicMessageQueue.document_id" is "queue:IN.DOCUMENTS | wait=120, url='mqs://mq_subscription:$conf::mq.password@mq.example.com/?QueueManager=QM1&Channel=SRV.CONN', repeat=True"
+And value for variable "AtomicMessageQueue.document_id" is "queue:IN.DOCUMENTS | wait=120, url='mqs://mq_subscription:$conf::mq.password@mq.example.com/?QueueManager=QM1&Channel=SRV.CONN', repeat=True"
 ...
 Given a user of type "RestApi" load testing "http://example.com"
 ...
@@ -53,7 +53,7 @@
 
 ### Using expression to get specific message
 
-And value of variable "AtomicMessageQueue.document_id" is "queue:IN.DOCUMENTS, expression:'//DocumentReference[text()='123abc']' | wait=120, url='mqs://mq_subscription:$conf::mq.password@mq.example.com/?QueueManager=QM1&Channel=SRV.CONN', repeat=True"
+And value for variable "AtomicMessageQueue.document_id" is "queue:IN.DOCUMENTS, expression:'//DocumentReference[text()='123abc']' | wait=120, url='mqs://mq_subscription:$conf::mq.password@mq.example.com/?QueueManager=QM1&Channel=SRV.CONN', repeat=True"
 And set response content type to "application/xml"
 ...
 Given a user of type "RestApi" load testing "http://example.com"
diff --git a/grizzly/testdata/variables/random_integer.py b/grizzly/testdata/variables/random_integer.py
index 1e04096f..b503c144 100644
--- a/grizzly/testdata/variables/random_integer.py
+++ b/grizzly/testdata/variables/random_integer.py
@@ -11,7 +11,7 @@
 ## Example
 
 ```gherkin
-And value of variable "AtomicRandomInteger.weight" is "10..30"
+And value for variable "AtomicRandomInteger.weight" is "10..30"
 ```
 
 This can then be used in a template:
diff --git a/grizzly/testdata/variables/random_string.py b/grizzly/testdata/variables/random_string.py
index 4bccccc5..c175030e 100644
--- a/grizzly/testdata/variables/random_string.py
+++ b/grizzly/testdata/variables/random_string.py
@@ -19,7 +19,7 @@
 ## Example
 
 ```gherkin
-And value of variable "AtomicRandomString.registration_plate_number" is "%s%sZ%d%d0 | upper=True, count=100"
+And value for variable "AtomicRandomString.registration_plate_number" is "%s%sZ%d%d0 | upper=True, count=100"
 ```
 
 This can then be used in a template:
diff --git a/grizzly/testdata/variables/servicebus.py b/grizzly/testdata/variables/servicebus.py
index 5c47c9c3..8cc1a254 100644
--- a/grizzly/testdata/variables/servicebus.py
+++ b/grizzly/testdata/variables/servicebus.py
@@ -56,7 +56,7 @@
 ## Example
 
 ```gherkin
-And value of variable "AtomicServiceBus.document_id" is "queue:documents-in | wait=120, url=$conf::sb.endpoint, repeat=True"
+And value for variable "AtomicServiceBus.document_id" is "queue:documents-in | wait=120, url=$conf::sb.endpoint, repeat=True"
 ...
 Given a user of type "RestApi" load testing "http://example.com"
 ...
@@ -75,7 +75,7 @@
 be specified for the endpint, e.g. `application/xml`.
 
 ```gherking
-And value of variable "AtomicServiceBus.tpm_document" is "queue:documents-in | wait=120, url=$conf::sb.endpoint, repeat=True, content_type=json, expression='$.document[?(@.name=='TPM Report')'"
+And value for variable "AtomicServiceBus.tpm_document" is "queue:documents-in | wait=120, url=$conf::sb.endpoint, repeat=True, content_type=json, expression='$.document[?(@.name=='TPM Report')'"
 ```
 '''  # noqa: E501
 import logging
diff --git a/tests/conftest.py b/tests/conftest.py
index aa384a50..32024c18 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,13 +1,15 @@
-from typing import Generator
+from typing import Generator, List
 
 import pytest
 
 from _pytest.tmpdir import TempPathFactory
+from _pytest.config import Config
 
 from pytest_mock.plugin import MockerFixture
 
 from .fixtures import (
     AtomicVariableCleanupFixture,
+    BehaveContextFixture,
     LocustFixture,
     NoopZmqFixture,
     ParamikoFixture,
@@ -15,9 +17,27 @@
     RequestTaskFixture,
     GrizzlyFixture,
     ResponseContextManagerFixture,
+    Webserver,
 )
 
 
+PYTEST_TIMEOUT = 120
+
+
+# if we're only running E2E tests, set global timeout
+def pytest_configure(config: Config) -> None:
+    target = getattr(config.known_args_namespace, 'file_or_dir', ['foobar'])
+    if len(target) > 0 and 'tests/e2e' in target[0]:
+        config._inicache['timeout'] = PYTEST_TIMEOUT
+
+
+# also, add markers for each test function that starts with test_e2e_, if we're running everything
+def pytest_collection_modifyitems(items: List[pytest.Function]) -> None:
+    for item in items:
+        if item.originalname.startswith('test_e2e_') and item.get_closest_marker('timeout') is None:
+            item.add_marker(pytest.mark.timeout(PYTEST_TIMEOUT))
+
+
 def _atomicvariable_cleanup() -> Generator[AtomicVariableCleanupFixture, None, None]:
     yield AtomicVariableCleanupFixture()
 
@@ -60,6 +80,17 @@ def _response_context_manager() -> Generator[ResponseContextManagerFixture, None
     yield ResponseContextManagerFixture()
 
 
+def _webserver() -> Generator[Webserver, None, None]:
+    with Webserver() as fixture:
+        yield fixture
+
+
+@pytest.mark.usefixtures('tmp_path_factory')
+def _behave_context_fixture(tmp_path_factory: TempPathFactory) -> Generator[BehaveContextFixture, None, None]:
+    with BehaveContextFixture(tmp_path_factory) as fixture:
+        yield fixture
+
+
 cleanup = pytest.fixture()(_atomicvariable_cleanup)
 locust_fixture = pytest.fixture()(_locust_fixture)
 paramiko_fixture = pytest.fixture()(_paramiko_fixture)
@@ -68,3 +99,5 @@ def _response_context_manager() -> Generator[ResponseContextManagerFixture, None
 grizzly_fixture = pytest.fixture(scope='function')(_grizzly_fixture)
 noop_zmq = pytest.fixture()(_noop_zmq)
 response_context_manager_fixture = pytest.fixture()(_response_context_manager)
+webserver = pytest.fixture(scope='session')(_webserver)
+behave_context_fixture = pytest.fixture(scope='session')(_behave_context_fixture)
diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/e2e/steps/__init__.py b/tests/e2e/steps/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/e2e/steps/background/__init__.py b/tests/e2e/steps/background/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/e2e/steps/background/test_setup.py b/tests/e2e/steps/background/test_setup.py
new file mode 100644
index 00000000..801f6cc1
--- /dev/null
+++ b/tests/e2e/steps/background/test_setup.py
@@ -0,0 +1,179 @@
+from tempfile import NamedTemporaryFile
+from typing import cast, Dict, Any, List
+
+import pytest
+import yaml
+
+from behave.runner import Context
+from grizzly.context import GrizzlyContext
+
+from ....fixtures import BehaveContextFixture
+
+
+@pytest.mark.parametrize('url', [
+    'influxdb://grizzly:password@influx.example.com/grizzly-statistics',
+    'insights://insights.example.com/?Testplan=grizzly-statistics&InstrumentationKey=asdfasdf=',
+    'influxdb://$conf::statistics.username:$conf::statistics.password@influx.example.com/$conf::statistics.database',
+])
+def test_e2e_step_setup_save_statistics(behave_context_fixture: BehaveContextFixture, url: str) -> None:
+    env_conf: Dict[str, Any] = {
+        'configuration': {
+            'statistics': {
+                'username': 'grizzly',
+                'password': 'password',
+                'database': 'grizzly-statistics',
+            }
+        }
+    }
+
+    def validate_statistics_url(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        test_url = data.pop('url')
+
+        for key, value in data.items():
+            test_url = test_url.replace(f'$conf::{key}', value)
+
+        assert grizzly.setup.statistics_url == test_url, f'{grizzly.setup.statistics_url} != {test_url}'
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [
+        {
+            'url': url,
+            'statistics.username': env_conf['configuration']['statistics']['username'],
+            'statistics.password': env_conf['configuration']['statistics']['password'],
+            'statistics.database': env_conf['configuration']['statistics']['database'],
+        }
+    ]
+
+    behave_context_fixture.add_validator(validate_statistics_url, table=table)
+
+    feature_file = behave_context_fixture.test_steps(
+        background=[
+            f'And save statistics to "{url}"',
+        ],
+        identifier=url,
+    )
+
+    with NamedTemporaryFile(delete=True, suffix='.yaml') as env_conf_file:
+        env_conf_file.write(yaml.dump(env_conf, Dumper=yaml.Dumper).encode())
+        env_conf_file.flush()
+
+        rc, _ = behave_context_fixture.execute(feature_file, env_conf_file=env_conf_file.name)
+
+        assert rc == 0
+
+
+@pytest.mark.parametrize('level', [
+    'INFO',
+    'DEBUG',
+    'WARNING',
+    'ERROR',
+])
+def test_e2e_step_setup_log_level(behave_context_fixture: BehaveContextFixture, level: str) -> None:
+    def validate_log_level(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        test_level = data.pop('level')
+
+        assert grizzly.setup.log_level == test_level, f'{grizzly.setup.log_level} != {test_level}'
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [{
+        'level': level,
+    }]
+
+    behave_context_fixture.add_validator(validate_log_level, table=table)
+
+    feature_file = behave_context_fixture.test_steps(
+        background=[
+            f'And log level is "{level}"',
+        ],
+        identifier=level,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+@pytest.mark.parametrize('timespan', [
+    '10s',
+    '1h2m',
+    '40m2s',
+    'asdf',
+])
+def test_e2e_step_setup_run_time(behave_context_fixture: BehaveContextFixture, timespan: str) -> None:
+    def validate_run_time(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        timespan = data.pop('timespan')
+
+        assert grizzly.setup.timespan == timespan, f'{grizzly.setup.timespan} != {timespan}'
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [{
+        'timespan': timespan,
+    }]
+
+    behave_context_fixture.add_validator(validate_run_time, table=table)
+
+    feature_file = behave_context_fixture.test_steps(
+        background=[
+            f'And run for maximum "{timespan}"'
+        ],
+        identifier=timespan,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+@pytest.mark.parametrize('name,value,expected', [
+    ('token.url', 'http://example.com/api/auth', '{"token": {"url": "http://example.com/api/auth"}}',),
+    ('token/client id', 'aaaa-bbbb-cccc-dddd', '{"token": {"client_id": "aaaa-bbbb-cccc-dddd"}}',),
+    ('log_all_requests', 'True', '{"log_all_requests": true}',),
+    ('run_id', '13', '{"run_id": 13}',),
+])
+def test_e2e_step_setup_global_context_variable(behave_context_fixture: BehaveContextFixture, name: str, value: str, expected: str) -> None:
+    def validate_global_context_variable(context: Context) -> None:
+        from json import loads as jsonloads
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        global_context = jsonloads(data['expected'])
+        global_context['hello'] = {'world': 'foobar'}
+        if 'token' not in global_context:
+            global_context['token'] = {'client_secret': 'something'}
+        else:
+            global_context['token'].update({'client_secret': 'something'})
+
+        assert grizzly.setup.global_context == global_context
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [{
+        'expected': expected,
+    }]
+
+    behave_context_fixture.add_validator(validate_global_context_variable, table=table)
+
+    feature_file = behave_context_fixture.test_steps(
+        background=[
+            f'And set global context variable "{name}" to "{value}"',
+            'And set global context variable "hello.world" to "foobar"',
+            'And set global context variable "token/client_secret" to "something"',
+        ],
+        identifier=name,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
diff --git a/tests/e2e/steps/background/test_shapes.py b/tests/e2e/steps/background/test_shapes.py
new file mode 100644
index 00000000..c493a388
--- /dev/null
+++ b/tests/e2e/steps/background/test_shapes.py
@@ -0,0 +1,88 @@
+from typing import cast, List, Dict
+
+import pytest
+
+from behave.runner import Context
+from grizzly.context import GrizzlyContext
+
+from ....fixtures import BehaveContextFixture
+
+
+@pytest.mark.parametrize('count', [
+    '5', '1', "{{ user_count }}",
+])
+def test_e2e_step_shapes_user_count(behave_context_fixture: BehaveContextFixture, count: str) -> None:
+    def validate_user_count(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        user_count = int(data['user_count'].replace('{{ user_count }}', '10'))
+
+        assert grizzly.setup.user_count == user_count, f'{grizzly.setup.user_count} != {user_count}'
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [
+        {
+            'user_count': str(count),
+        }
+    ]
+
+    suffix = 's'
+    try:
+        if int(count) <= 1:
+            suffix = ''
+    except:
+        pass
+
+    behave_context_fixture.add_validator(validate_user_count, table=table)
+
+    feature_file = behave_context_fixture.test_steps(
+        background=[
+            'Then ask for value of variable "user_count"',
+            f'Given "{count}" user{suffix}',
+        ],
+        scenario=[
+            f'And repeat for "{count}" iteration{suffix}',
+        ],
+        identifier=count,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file, testdata={'user_count': '10'})
+
+    assert rc == 0
+
+
+@pytest.mark.parametrize('rate', [
+    '1', '0.5', "{{ spawn_rate }}",
+])
+def test_e2e_step_shapes_spawn_rate(behave_context_fixture: BehaveContextFixture, rate: str) -> None:
+    def validate_spawn_rate(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        spawn_rate = float(data['spawn_rate'].replace('{{ spawn_rate }}', '0.01'))
+
+        assert grizzly.setup.spawn_rate == spawn_rate, f'{grizzly.setup.spawn_rate} != {spawn_rate}'
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [
+        {
+            'spawn_rate': str(rate),
+        }
+    ]
+
+    behave_context_fixture.add_validator(validate_spawn_rate, table=table)
+
+    feature_file = behave_context_fixture.test_steps(
+        background=[
+            'Then ask for value of variable "spawn_rate"',
+            f'Given spawn rate is "{rate}" users per second',
+        ],
+        identifier=rate,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file, testdata={'spawn_rate': '0.001'})
+
+    assert rc == 0
diff --git a/tests/e2e/steps/scenario/__init__.py b/tests/e2e/steps/scenario/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/e2e/steps/scenario/test_response.py b/tests/e2e/steps/scenario/test_response.py
new file mode 100644
index 00000000..768a6bbe
--- /dev/null
+++ b/tests/e2e/steps/scenario/test_response.py
@@ -0,0 +1,299 @@
+from typing import cast, Dict, List
+from itertools import product
+
+import pytest
+
+from behave.runner import Context
+from grizzly.context import GrizzlyContext
+from grizzly.types import ResponseTarget
+
+from ....fixtures import BehaveContextFixture
+
+
+@pytest.mark.parametrize('target', [target for target in ResponseTarget])
+def test_e2e_step_response_save_matches(behave_context_fixture: BehaveContextFixture, target: ResponseTarget) -> None:
+    def add_validate_save_handler(context: Context) -> None:
+        from grizzly.tasks import RequestTask
+        from grizzly.users.base.response_handler import SaveHandlerAction
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        data = list(context.table)[0].as_dict()
+        handler_type = data['target']
+
+        grizzly.scenario.tasks.pop()  # latest task is a dummy task
+
+        assert len(grizzly.scenario.orphan_templates) == 1, 'unexpected number of orphan templates'
+        assert grizzly.scenario.orphan_templates[0] == '{{ expression }}', f'{grizzly.scenario.orphan_templates[0]} != {{ expression }}'
+
+        request = grizzly.scenario.tasks[-1]
+
+        assert isinstance(request, RequestTask), f'{request.__class__.__name__} != RequestTask'
+
+        handlers = getattr(request.response.handlers, handler_type)
+
+        assert len(handlers) == 1, f'unexpected number of {target} handlers'
+        handler = handlers[0]
+        assert isinstance(handler, SaveHandlerAction), f'{handler.__class__.__name__} != SaveHandlerAction'
+        assert handler.variable == 'tmp', f'{handler.variable} != tmp'
+        assert handler.expression == '{{ expression }}', f'{handler.expression} != {{{{ expression }}}}'
+        assert handler.match_with == 'foo[bar]?', f'{handler.match_with} != foo[bar]?'
+        assert handler.expected_matches == 10, f'{handler.expected_matches} != 10'
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [{
+        'target': target.name.lower(),
+    }]
+
+    behave_context_fixture.add_validator(
+        add_validate_save_handler,
+        table=table,
+    )
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And value for variable "tmp" is "none"',
+            f'Then get request with name "{target.name.lower()}-handler" from endpoint "/api/test | content_type=json"',
+            f'Then save response {target.name.lower()} "{{{{ expression }}}} | expected_matches=10" that matches "foo[bar]?" in variable "tmp"',
+        ],
+        identifier=target.name,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+@pytest.mark.parametrize('target', [target for target in ResponseTarget])
+def test_e2e_step_response_save(behave_context_fixture: BehaveContextFixture, target: ResponseTarget) -> None:
+    def validate_save_handler(context: Context) -> None:
+        from grizzly.tasks import RequestTask
+        from grizzly.users.base.response_handler import SaveHandlerAction
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        data = list(context.table)[0].as_dict()
+        handler_type = data['target']
+
+        grizzly.scenario.tasks.pop()  # latest task is a dummy task
+
+        assert len(grizzly.scenario.orphan_templates) == 0, 'unexpected number of orphan templates'
+
+        request = grizzly.scenario.tasks[-1]
+
+        assert isinstance(request, RequestTask), f'{request.__class__.__name__} != RequestTask'
+
+        handlers = getattr(request.response.handlers, handler_type)
+
+        assert len(handlers) == 1, f'unexpected number of {target} handlers'
+        handler = handlers[0]
+        assert isinstance(handler, SaveHandlerAction), f'{handler.__class__.__name__} != SaveHandlerAction'
+        assert handler.variable == 'foobar', f'{handler.variable} != foobar'
+        assert handler.expression == '$.hello.world', f'{handler.expression} != $.hello.world'
+        assert handler.match_with == '.*', f'{handler.match_with} != .*'
+        assert handler.expected_matches == 1, f'{handler.expected_matches} != 1'
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [{
+        'target': target.name.lower(),
+    }]
+
+    behave_context_fixture.add_validator(
+        validate_save_handler,
+        table=table,
+    )
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And value for variable "foobar" is "none"',
+            f'Then get request with name "{target.name.lower()}-handler" from endpoint "/api/test | content_type=json"',
+            f'Then save response {target.name.lower()} "$.hello.world" in variable "foobar"',
+        ],
+        identifier=target.name,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+@pytest.mark.parametrize('target,condition', list(product(ResponseTarget, ['is', 'is not'])))
+def test_e2e_step_response_validate(behave_context_fixture: BehaveContextFixture, target: ResponseTarget, condition: str) -> None:
+    def validate_validation_handler(context: Context) -> None:
+        from grizzly.tasks import RequestTask
+        from grizzly.users.base.response_handler import ValidationHandlerAction
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        data = list(context.table)[0].as_dict()
+        handler_type = data['target']
+        textual_condition = data['condition']
+
+        grizzly.scenario.tasks.pop()  # latest task is a dummy task
+
+        assert len(grizzly.scenario.orphan_templates) == 0, 'unexpected number of orphan templates'
+
+        request = grizzly.scenario.tasks[-1]
+
+        assert isinstance(request, RequestTask), f'{request.__class__.__name__} != RequestTask'
+
+        handlers = getattr(request.response.handlers, handler_type)
+
+        assert len(handlers) == 1, f'unexpected number of {target} handlers'
+        handler = handlers[0]
+        assert isinstance(handler, ValidationHandlerAction), f'{handler.__class__.__name__} != ValidationHandlerAction'
+        if textual_condition == 'is not':
+            assert not handler.condition
+        else:
+            assert handler.condition
+
+        assert handler.expression == '$.hello.world', f'{handler.expression} != $.hello.world'
+        assert handler.match_with == 'foo[bar]?', f'{handler.match_with} != foo[bar]?'
+        assert handler.expected_matches == 1, f'{handler.expected_matches} != 1'
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [{
+        'target': target.name.lower(),
+        'condition': condition,
+    }]
+
+    behave_context_fixture.add_validator(
+        validate_validation_handler,
+        table=table,
+    )
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            f'Then get request with name "{target.name.lower()}-handler" from endpoint "/api/test | content_type=json"',
+            f'When response {target.name.lower()} "$.hello.world" {condition} "foo[bar]?" stop user',
+        ],
+        identifier=target.name,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+@pytest.mark.parametrize('status_codes', [
+    '200,302',
+    '-200,404',
+])
+def test_e2e_step_allow_status_codes(behave_context_fixture: BehaveContextFixture, status_codes: str) -> None:
+    def validate_status_codes(context: Context) -> None:
+        from grizzly.tasks import RequestTask
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        data = list(context.table)[0].as_dict()
+        status_codes = [int(status_code.strip()) for status_code in data['status_codes'].split(',') if status_code.strip() != '-200']
+
+        grizzly.scenario.tasks.pop()  # latest task is a dummy task
+
+        request = grizzly.scenario.tasks[-1]
+
+        assert isinstance(request, RequestTask), f'{request.__class__.__name__} != RequestTask'
+        assert request.response.status_codes == status_codes
+        assert len(grizzly.scenario.orphan_templates) == 0, 'unexpected number of orphan templates'
+
+    table: List[Dict[str, str]] = [{
+        'status_codes': status_codes,
+    }]
+
+    behave_context_fixture.add_validator(
+        validate_status_codes,
+        table=table,
+    )
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'Then get request with name "test-allow-status-codes" from endpoint "/api/test"',
+            f'And allow response status codes "{status_codes}"',
+        ],
+        identifier=status_codes,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+    assert rc == 0
+
+
+def test_e2e_step_allow_status_codes_table(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_status_codes_table(context: Context) -> None:
+        from grizzly.tasks import RequestTask
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        request = grizzly.scenario.tasks[-2]
+        assert isinstance(request, RequestTask), f'{request.__class__.__name__} != RequestTask'
+        assert request.name == 'test-get-2', f'{request.name} != test-get-2'
+        assert request.response.status_codes == [404], f'{str(request.response.status_codes)} != [404]'
+
+        request = grizzly.scenario.tasks[-3]
+        assert isinstance(request, RequestTask), f'{request.__class__.__name__} != RequestTask'
+        assert request.name == 'test-get-1', f'{request.name} != test-get-1'
+        assert request.response.status_codes == [200, 302], f'{str(request.response.status_codes)} != [200, 302]'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(
+        validate_status_codes_table,
+    )
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'Then get request with name "test-get-1" from endpoint "/api/test"',
+            'Then get request with name "test-get-2" from endpoint "/api/test"',
+            '''And allow response status codes
+  | status   |
+  | 200, 302 |
+  | -200,404 |
+''',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+    assert rc == 0
+
+
+@pytest.mark.parametrize('content_type', [
+    'json', 'application/json',
+    'xml', 'application/xml',
+    'plain', 'text/plain',
+])
+def test_e2e_step_response_content_type(behave_context_fixture: BehaveContextFixture, content_type: str) -> None:
+    def validate_response_content_type(context: Context) -> None:
+        from grizzly.tasks import RequestTask
+        from grizzly_extras.transformer import TransformerContentType
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        request = grizzly.scenario.tasks[-2]
+        assert isinstance(request, RequestTask), f'{request.__class__.__name__} != RequestTask'
+        assert request.name == 'test-get-1', f'{request.name} != test-get-1'
+        assert request.response.content_type == TransformerContentType.from_string(data['content_type'])
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [{
+        'content_type': content_type,
+    }]
+
+    behave_context_fixture.add_validator(
+        validate_response_content_type,
+        table=table,
+    )
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'Then get request with name "test-get-1" from endpoint "/api/test"',
+            f'And set response content type to "{content_type}"',
+        ],
+        identifier=content_type,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+    assert rc == 0
diff --git a/tests/e2e/steps/scenario/test_results.py b/tests/e2e/steps/scenario/test_results.py
new file mode 100644
index 00000000..4f18fd02
--- /dev/null
+++ b/tests/e2e/steps/scenario/test_results.py
@@ -0,0 +1,71 @@
+from typing import cast
+
+from behave.runner import Context
+from grizzly.context import GrizzlyContext
+
+from ....fixtures import BehaveContextFixture
+
+
+def test_e2e_step_results_fail_ratio(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_fail_ratio(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        assert grizzly.scenario.validation.fail_ratio == 96 / 100.0, f'{grizzly.scenario.validation.fail_ratio} != 0.96'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_fail_ratio)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'When fail ratio is greater than "96"% fail scenario',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_results_avg_response_time(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_avg_response_time(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        assert grizzly.scenario.validation.avg_response_time == 200, f'{grizzly.scenario.validation.avg_response_time} != 200'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_avg_response_time)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'When average response time is greater than "200" milliseconds fail scenario',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_results_response_time_percentile(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_response_time_percentile(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        assert grizzly.scenario.validation.response_time_percentile is not None
+        assert grizzly.scenario.validation.response_time_percentile.response_time == 333, f'{grizzly.scenario.validation.response_time_percentile.response_time} != 333'
+        assert grizzly.scenario.validation.response_time_percentile.percentile == 91 / 100.0, f'{grizzly.scenario.validation.response_time_percentile.percentile} != 0.91'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_response_time_percentile)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'When response time percentile "91"% is greater than "333" milliseconds fail scenario',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
diff --git a/tests/e2e/steps/scenario/test_setup.py b/tests/e2e/steps/scenario/test_setup.py
new file mode 100644
index 00000000..c8925d32
--- /dev/null
+++ b/tests/e2e/steps/scenario/test_setup.py
@@ -0,0 +1,289 @@
+import textwrap
+
+from typing import cast, List, Dict
+import pytest
+
+from behave.runner import Context
+from grizzly.context import GrizzlyContext
+
+from ....fixtures import BehaveContextFixture
+
+
+@pytest.mark.parametrize('name,value,expected', [
+    ('token.url', 'http://example.com/api/auth', '{"token": {"url": "http://example.com/api/auth"}}',),
+    ('token/client id', 'aaaa-bbbb-cccc-dddd', '{"token": {"client_id": "aaaa-bbbb-cccc-dddd"}}',),
+    ('log_all_requests', 'True', '{"log_all_requests": true}',),
+    ('run_id', '13', '{"run_id": 13}',),
+])
+def test_e2e_step_setup_set_context_variable(behave_context_fixture: BehaveContextFixture, name: str, value: str, expected: str) -> None:
+    def validate_context_variable(context: Context) -> None:
+        from json import loads as jsonloads
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        expected = jsonloads(data['expected'])
+        expected['hello'] = {'world': 'foobar'}
+        if 'token' not in expected:
+            expected['token'] = {'client_secret': 'something'}
+        else:
+            expected['token'].update({'client_secret': 'something'})
+
+        actual = grizzly.scenario.context.copy()
+
+        try:
+            del actual['host']
+        except KeyError:
+            pass
+
+        assert actual == expected, f'{str(actual)} != {str(expected)}'
+        assert grizzly.scenario.context.get('host', None) == 'http://localhost:1'  # added by fixture
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [{
+        'expected': expected,
+    }]
+
+    behave_context_fixture.add_validator(validate_context_variable, table=table)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            f'And set context variable "{name}" to "{value}"',
+            'And set context variable "hello.world" to "foobar"',
+            'And set context variable "token/client_secret" to "something"',
+        ],
+        identifier=name,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+@pytest.mark.parametrize('iterations', [
+    '10', '1', '{{ leveranser * 0.25 }}',
+])
+def test_e2e_step_setup_iterations(behave_context_fixture: BehaveContextFixture, iterations: str) -> None:
+    def validate_iterations(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        iterations = int(data['iterations'].replace('{{ leveranser * 0.25 }}', '25'))
+
+        assert grizzly.scenario.iterations == iterations, f'{grizzly.scenario.iterations} != {iterations}'
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [{
+        'iterations': iterations,
+    }]
+
+    suffix = 's'
+    try:
+        if int(iterations) <= 1:
+            suffix = ''
+    except:
+        pass
+
+    behave_context_fixture.add_validator(validate_iterations, table=table)
+
+    feature_file = behave_context_fixture.test_steps(
+        background=[
+            'Then ask for value of variable "leveranser"',
+        ],
+        scenario=[
+            f'And repeat for "{iterations}" iteration{suffix}'
+        ],
+        identifier=iterations,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file, testdata={'leveranser': '100'})
+
+    assert rc == 0
+
+
+def test_e2e_step_setup_wait_time(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_wait_time(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        assert grizzly.scenario.wait.minimum == 0.3
+        assert grizzly.scenario.wait.maximum == 0.5
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_wait_time)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And wait time inbetween requests is random between "0.3" and "0.5" seconds',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_variable_value(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_variable_value(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        assert grizzly.state.variables.get('testdata_variable', None) == 'hello world!'
+        assert grizzly.state.variables.get('int_value', None) == 10
+        assert grizzly.state.variables.get('float_value', None) == 1.0
+        assert grizzly.state.variables.get('bool_value', False)
+        assert grizzly.state.variables.get('wildcard', None) == 'foobar'
+        assert grizzly.state.variables.get('nested_value', None) == 'hello world!'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_variable_value)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And value for variable "testdata_variable" is "hello world!"',
+            'And value for variable "int_value" is "10"',
+            'And value for variable "float_value" is "1.0"',
+            'And value for variable "bool_value" is "True"',
+            'And value for variable "wildcard" is "foobar"',
+            'And value for variable "nested_value" is "{{ testdata_variable }}"',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_set_variable_alias(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_variable_alias(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        alias = grizzly.state.alias
+
+        assert len(alias) == 2, 'unexpected number of aliases'
+        alias_username = alias.get('AtomicCsvRow.users.username', None)
+        assert alias_username == 'auth.user.username', f'{alias_username} != auth.user.username'
+        alias_password = alias.get('AtomicCsvRow.users.password', None)
+        assert alias_password == 'auth.user.password', f'{alias_password} != auth.user.password'
+        variable = grizzly.state.variables.get('AtomicCsvRow.users', None)
+        assert variable == 'users.csv | repeat=True', f'{variable} != users.csv | repeat=True'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_variable_alias)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And value for variable "AtomicCsvRow.users" is "users.csv | repeat=True"',
+            'And set alias "auth.user.username" for variable "AtomicCsvRow.users.username"',
+            'And set alias "auth.user.password" for variable "AtomicCsvRow.users.password"',
+        ],
+    )
+
+    (behave_context_fixture.root / 'features' / 'requests' / 'users.csv').write_text(textwrap.dedent(
+        '''username,password
+        grizzly,secret
+        '''
+    ))
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_setup_log_all_requests(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_log_all_requests(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        assert grizzly.scenario.context.get('log_all_requests', False)
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_log_all_requests)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And log all requests',
+        ]
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_setup_stop_user_on_failure(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_stop_user_on_failure(context: Context) -> None:
+        from locust.exception import StopUser
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        assert grizzly.scenario.failure_exception is not None
+        assert isinstance(grizzly.scenario.failure_exception(), StopUser), 'failure exception is not StopUser'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_stop_user_on_failure)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And stop user on failure',
+        ]
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_setup_restart_scenario_on_failure(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_restart_scenario_on_failure(context: Context) -> None:
+        from grizzly.exceptions import RestartScenario
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        assert grizzly.scenario.failure_exception is not None
+        assert isinstance(grizzly.scenario.failure_exception(), RestartScenario), 'failure exception is not RestartScenario'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_restart_scenario_on_failure)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And restart scenario on failure',
+        ]
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_setup_metadata(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_metadata(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        assert grizzly.state.variables.get('nested_value', None) == 10, 'nested_value variable is not 10'
+
+        metadata = grizzly.scenario.context.get('metadata', None)
+        assert metadata == {
+            'Content-Type': 'application/xml',
+            'Ocp-Apim-Subscription-Key': '9asdf00asdf00adsf034',
+            'nested': 10,
+        }, f'unexpected metadata: {str(metadata)}'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_metadata)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And value for variable "nested_value" is "10"',
+            'And metadata "Content-Type" is "application/xml"',
+            'And metadata "Ocp-Apim-Subscription-Key" is "9asdf00asdf00adsf034"',
+            'And metadata "nested" is "{{ nested_value }}"',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
diff --git a/tests/e2e/steps/scenario/test_tasks.py b/tests/e2e/steps/scenario/test_tasks.py
new file mode 100644
index 00000000..0568e508
--- /dev/null
+++ b/tests/e2e/steps/scenario/test_tasks.py
@@ -0,0 +1,672 @@
+from json import dumps as jsondumps
+from typing import cast
+from textwrap import dedent
+
+from behave.runner import Context
+from grizzly.context import GrizzlyContext
+
+from ....fixtures import BehaveContextFixture
+
+
+def test_e2e_step_task_request_text_with_name_to_endpoint(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_requests(context: Context) -> None:
+        from json import loads as jsonloads
+        from grizzly.tasks import RequestTask
+        from grizzly.types import RequestMethod
+        from grizzly_extras.transformer import TransformerContentType
+        from jinja2 import Template
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        tasks = grizzly.scenario.tasks
+
+        tasks.pop()  # remove dummy task added by fixture
+
+        assert len(tasks) == 5, f'{len(tasks)} != 5'
+
+        request = tasks[0]
+        assert isinstance(request, RequestTask)
+        assert request.method == RequestMethod.POST, f'{request.method} != RequestMethod.POST'
+        assert request.name == 'test-post', f'{request.name} != test-post'
+        assert request.endpoint == '/api/test', f'{request.endpoint} != /api/test'
+        assert isinstance(request.template, Template), 'request.template is not a Template'
+        assert request.source is not None, 'request.source is None'
+        assert jsonloads(request.source) == {'test': 'post'}, f"{request.source} != {'test': 'post'}"
+        assert request.response.content_type == TransformerContentType.UNDEFINED, f'{request.response.content_type} != TransformerContentType.UNDEFINED'
+
+        request = tasks[1]
+        assert isinstance(request, RequestTask)
+        assert request.method == RequestMethod.PUT
+        assert request.name == 'test-put'
+        assert request.endpoint == '/api/test'
+        assert isinstance(request.template, Template)
+        assert request.source is not None
+        assert jsonloads(request.source) == {'test': 'put'}
+        assert request.response.content_type == TransformerContentType.JSON
+
+        request = tasks[2]
+        assert isinstance(request, RequestTask)
+        assert request.method == RequestMethod.GET
+        assert request.name == 'test-get'
+        assert request.endpoint == '/api/test'
+        assert request.template is None
+        assert request.source is None
+        assert request.response.content_type == TransformerContentType.UNDEFINED
+
+        request = tasks[3]
+        assert isinstance(request, RequestTask)
+        assert request.method == RequestMethod.SEND
+        assert request.name == 'test-send'
+        assert request.endpoint == 'queue:receive-queue'
+        assert isinstance(request.template, Template)
+        assert request.source is not None
+        assert jsonloads(request.source) == {'test': 'send'}
+        assert request.response.content_type == TransformerContentType.UNDEFINED
+
+        request = tasks[4]
+        assert isinstance(request, RequestTask)
+        assert request.method == RequestMethod.RECEIVE
+        assert request.name == 'test-receive'
+        assert request.endpoint == 'queue:receive-queue'
+        assert request.template is None
+        assert request.source is None
+        assert request.response.content_type == TransformerContentType.XML
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_requests)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            dedent('''Then post request with name "test-post" to endpoint "/api/test"
+                """
+                {
+                    "test": "post"
+                }
+                """
+            '''),
+            dedent('''Then put request with name "test-put" to endpoint "/api/test | content_type=json"
+                """
+                {
+                    "test": "put"
+                }
+                """
+            '''),
+            'Then get request with name "test-get" from endpoint "/api/test"',
+            dedent('''Then send request with name "test-send" to endpoint "queue:receive-queue"
+                """
+                {
+                    "test": "send"
+                }
+                """
+            '''),
+            'Then receive request with name "test-receive" from endpoint "queue:receive-queue | content_type=xml"',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_task_request_file_with_name_endpoint(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_requests(context: Context) -> None:
+        from json import loads as jsonloads
+        from grizzly.tasks import RequestTask
+        from grizzly.types import RequestMethod
+        from grizzly_extras.transformer import TransformerContentType
+        from jinja2 import Template
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        tasks = grizzly.scenario.tasks
+
+        tasks.pop()  # remove dummy task added by fixture
+
+        assert len(tasks) == 3, f'{len(tasks)} != 3'
+
+        request = tasks[0]
+        assert isinstance(request, RequestTask)
+        assert request.method == RequestMethod.SEND, f'{request.method} != RequestMethod.SEND'
+        assert request.name == 'test-send', f'{request.name} != test-send'
+        assert request.endpoint == 'queue:receive-queue', f'{request.endpoint} != queue:receive-queue'
+        assert isinstance(request.template, Template), 'request.template is not a Template'
+        assert request.source is not None, 'request.source is None'
+        assert jsonloads(request.source) == {'test': 'request-send'}, f"{request.source} != {'test': 'request-send'}"
+        assert request.response.content_type == TransformerContentType.XML, f'{request.response.content_type} != TransformerContentType.XML'
+        assert len(request.get_templates()) == 0
+
+        request = tasks[1]
+        assert isinstance(request, RequestTask)
+        assert request.method == RequestMethod.POST
+        assert request.name == 'test-post'
+        assert request.endpoint == '/api/test'
+        assert isinstance(request.template, Template)
+        assert request.source is not None
+        assert jsonloads(request.source) == {'test': 'request-{{ post }}'}
+        assert request.response.content_type == TransformerContentType.JSON
+        assert len(request.get_templates()) == 1
+
+        request = tasks[2]
+        assert isinstance(request, RequestTask)
+        assert request.method == RequestMethod.PUT
+        assert request.name == 'test-put-{{ foo }}'
+        assert request.endpoint == '/api/{{ bar }}'
+        assert isinstance(request.template, Template)
+        assert request.source is not None
+        assert jsonloads(request.source) == {'test': 'request-put-{{ foobar }}'}
+        assert request.response.content_type == TransformerContentType.UNDEFINED
+        assert len(request.get_templates()) == 3
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_requests)
+
+    request_files = behave_context_fixture.root / 'features' / 'requests' / 'test'
+    request_files.mkdir(exist_ok=True)
+
+    (request_files / 'request-send.j2.json').write_text(jsondumps({'test': 'request-send'}))
+    (request_files / 'request-post.j2.json').write_text(jsondumps({'test': 'request-{{ post }}'}))
+    (request_files / 'request-put.j2.json').write_text(jsondumps({'test': 'request-put-{{ foobar }}'}))
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'Then send request "test/request-send.j2.json" with name "test-send" to endpoint "queue:receive-queue | content_type=xml"',
+            'Then post request "test/request-post.j2.json" with name "test-post" to endpoint "/api/test | content_type=json"',
+            'Then put request "test/request-put.j2.json" with name "test-put-{{ foo }}" to endpoint "/api/{{ bar }}"',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_task_request_file_with_name(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_requests(context: Context) -> None:
+        from json import loads as jsonloads
+        from grizzly.tasks import RequestTask
+        from grizzly.types import RequestMethod
+        from grizzly_extras.transformer import TransformerContentType
+        from jinja2 import Template
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        tasks = grizzly.scenario.tasks
+
+        tasks.pop()  # remove dummy task added by fixture
+
+        assert len(tasks) == 2, f'{len(tasks)} != 2'
+
+        for index, request in enumerate(tasks, start=1):
+            assert isinstance(request, RequestTask)
+            assert request.method == RequestMethod.POST
+            assert request.name == f'test-post-{index}'
+            assert request.endpoint == '/api/test'
+            assert isinstance(request.template, Template)
+            assert request.source is not None
+            assert jsonloads(request.source) == {'test': f'request-{{{{ post_{index} }}}}-{index}'}
+            assert request.response.content_type == TransformerContentType.JSON
+            assert len(request.get_templates()) == 1
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_requests)
+
+    request_files = behave_context_fixture.root / 'features' / 'requests' / 'test'
+    request_files.mkdir(exist_ok=True)
+
+    (request_files / 'request-post-1.j2.json').write_text(jsondumps({'test': 'request-{{ post_1 }}-1'}))
+    (request_files / 'request-post-2.j2.json').write_text(jsondumps({'test': 'request-{{ post_2 }}-2'}))
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'Then post request "test/request-post-1.j2.json" with name "test-post-1" to endpoint "/api/test | content_type=json"',
+            'Then post request "test/request-post-2.j2.json" with name "test-post-2"',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_task_request_text_with_name(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_requests(context: Context) -> None:
+        from json import loads as jsonloads
+        from grizzly.tasks import RequestTask
+        from grizzly.types import RequestMethod
+        from grizzly_extras.transformer import TransformerContentType
+        from jinja2 import Template
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        tasks = grizzly.scenario.tasks
+
+        tasks.pop()  # remove dummy task added by fixture
+
+        assert len(tasks) == 4, f'{len(tasks)} != 4'
+
+        for index, request in enumerate(tasks[:2], start=1):
+            assert isinstance(request, RequestTask)
+            assert request.method == RequestMethod.POST
+            assert request.name == f'test-post-{index}'
+            assert request.endpoint == '/api/test'
+            assert isinstance(request.template, Template)
+            assert request.source is not None
+            assert jsonloads(request.source) == {'value': f'test-post-{index}'}
+            assert request.response.content_type == TransformerContentType.JSON
+            assert len(request.get_templates()) == 0
+
+        for index, request in enumerate(tasks[2:], start=1):
+            assert isinstance(request, RequestTask)
+            assert request.method == RequestMethod.GET
+            assert request.name == f'test-get-{index}'
+            assert request.endpoint == '/api/test'
+            assert request.template is None
+            assert request.source is None
+            assert request.response.content_type == TransformerContentType.XML
+            assert len(request.get_templates()) == 0
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_requests)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            dedent('''Then post request with name "test-post-1" to endpoint "/api/test | content_type=json"
+                """
+                {
+                    "value": "test-post-1"
+                }
+                """
+            '''),
+            dedent('''Then post request with name "test-post-2"
+                """
+                {
+                    "value": "test-post-2"
+                }
+                """
+            '''),
+            'Then get request with name "test-get-1" from endpoint "/api/test | content_type=\"application/xml\""',
+            'Then get request with name "test-get-2"',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_task_wait_seconds(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_task_wait(context: Context) -> None:
+        from grizzly.tasks import WaitTask
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        tasks = grizzly.scenario.tasks
+        tasks.pop()
+
+        assert len(tasks) == 2
+
+        task = tasks[0]
+        assert isinstance(task, WaitTask)
+        assert task.time == 13.37
+
+        task = tasks[1]
+        assert isinstance(task, WaitTask)
+        assert task.time == 0.123
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_task_wait)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'Then wait for "13.37" seconds',
+            'Then wait for "0.123" seconds',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_task_print_message(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_task_wait(context: Context) -> None:
+        from grizzly.tasks import LogMessage
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        tasks = grizzly.scenario.tasks
+        tasks.pop()
+
+        assert len(tasks) == 2
+
+        task = tasks[0]
+        assert isinstance(task, LogMessage)
+        assert task.message == 'hello world!'
+        assert len(task.get_templates()) == 0
+
+        task = tasks[1]
+        assert isinstance(task, LogMessage)
+        assert task.message == 'foobar={{ foobar }}'
+        assert len(task.get_templates()) == 1
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_task_wait)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'Then print message "hello world!"',
+            'Then print message "foobar={{ foobar }}"',
+        ],
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_task_transform(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_transform(context: Context) -> None:
+        from grizzly.tasks import TransformerTask
+        from grizzly_extras.transformer import TransformerContentType, JsonTransformer
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        tasks = grizzly.scenario.tasks
+        tasks.pop()
+
+        assert len(tasks) == 2
+
+        task = tasks[0]
+        assert isinstance(task, TransformerTask)
+        assert task.content == '{{ document }}'
+        assert task.variable == 'document_id'
+        assert task.expression == '$.documents.id'
+        assert task.content_type == TransformerContentType.JSON
+        assert issubclass(task._transformer, JsonTransformer)
+        assert task.get_templates() == ['{{ document }}']
+        assert callable(task._parser)
+
+        task = tasks[1]
+        assert isinstance(task, TransformerTask)
+        assert task.content == '{{ document }}'
+        assert task.variable == 'document_title'
+        assert task.expression == '$.documents.title'
+        assert task.content_type == TransformerContentType.JSON
+        assert issubclass(task._transformer, JsonTransformer)
+        assert task.get_templates() == ['{{ document }}']
+        assert callable(task._parser)
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_transform)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And value for variable "document_id" is "None"',
+            'And value for variable "document_title" is "None"',
+            'And value for variable "document" is "{\"document\": {\"id\": \"DOCUMENT_8843-1\", \"title\": \"TPM Report 2021\"}}"',
+            'Then parse "{{ document }}" as "json" and save value of "$.documents.id" in variable "document_id"',
+            'Then parse "{{ document }}" as "json" and save value of "$.documents.title" in variable "document_title"',
+        ]
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_task_client_get_endpoint(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_client_task(context: Context) -> None:
+        from grizzly.types import RequestDirection
+        from grizzly.tasks.clients import HttpClientTask
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        tasks = grizzly.scenario.tasks
+        tasks.pop()
+
+        assert len(tasks) == 2
+
+        task = tasks[0]
+        assert isinstance(task, HttpClientTask)
+        assert task.direction == RequestDirection.FROM
+        assert task.endpoint == 'https://www.example.org/example.json'
+        assert task.variable == 'example_openapi', f'{task.variable} != example_openapi'
+        assert task.source is None
+        assert task.destination is None
+        assert task._short_name == 'Http'
+        assert task.get_templates() == []
+
+        task = tasks[1]
+        assert isinstance(task, HttpClientTask)
+        assert task.direction == RequestDirection.FROM
+        assert task.endpoint == '{{ endpoint }}'
+        assert task.variable == 'endpoint_result'
+        assert task.source is None
+        assert task.destination is None
+        assert task._short_name == 'Http'
+        assert task.get_templates() == ['{{ endpoint }}']
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_client_task)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And value for variable "example_openapi" is "None"',
+            'And value for variable "endpoint_result" is "None"',
+            'Then get "https://www.example.org/example.json" and save response in "example_openapi"',
+            'Then get "http://{{ endpoint }}" and save response in "endpoint_result"',
+        ]
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_task_client_put_endpoint_file_destination(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_client_task(context: Context) -> None:
+        from grizzly.types import RequestDirection
+        from grizzly.tasks.clients import BlobStorageClientTask
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        tasks = grizzly.scenario.tasks
+        tasks.pop()
+
+        assert len(tasks) == 2
+
+        task = tasks[0]
+        assert isinstance(task, BlobStorageClientTask)
+        assert task.direction == RequestDirection.TO
+        assert task.endpoint == 'bs://my-unsecure-storage?AccountKey=aaaabbb=&Container=my-container'
+        assert task.variable is None
+        assert task.source == 'test-file.json'
+        assert task.destination == 'uploaded-test-file.json'
+        assert task._short_name == 'BlobStorage'
+        assert task.get_templates() == []
+        assert task._endpoints_protocol == 'http'
+        assert task.account_name == 'my-unsecure-storage'
+        assert task.account_key == 'aaaabbb='
+        assert task.container == 'my-container'
+        assert task.connection_string == 'DefaultEndpointsProtocol=http;AccountName=my-unsecure-storage;AccountKey=aaaabbb=;EndpointSuffix=core.windows.net'
+
+        task = tasks[1]
+        assert isinstance(task, BlobStorageClientTask)
+        assert task.direction == RequestDirection.TO
+        assert task.endpoint == 'bss://my-storage?AccountKey=aaaabbb=&Container=my-container'
+        assert task.variable is None
+        assert task.source == 'test-files.json'
+        assert task.destination == 'uploaded-test-files.json'
+        assert task._short_name == 'BlobStorage'
+        assert task.get_templates() == []
+        assert task._endpoints_protocol == 'https'
+        assert task.account_name == 'my-storage'
+        assert task.account_key == 'aaaabbb='
+        assert task.container == 'my-container'
+        assert task.connection_string == 'DefaultEndpointsProtocol=https;AccountName=my-storage;AccountKey=aaaabbb=;EndpointSuffix=core.windows.net'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_client_task)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'Then put "test-file.json" to "bs://my-unsecure-storage?AccountKey=aaaabbb=&Container=my-container" as "uploaded-test-file.json"',
+            'Then put "test-files.json" to "bss://my-storage?AccountKey=aaaabbb=&Container=my-container" as "uploaded-test-files.json"',
+        ]
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_task_date(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_date_task(context: Context) -> None:
+        from grizzly.tasks import DateTask
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        tasks = grizzly.scenario.tasks
+        tasks.pop()
+
+        assert len(tasks) == 3
+
+        task = tasks[0]
+        assert isinstance(task, DateTask)
+        assert task.variable == 'date1'
+        assert task.value == '2022-01-17 12:21:37'
+        assert task.arguments == {
+            'timezone': 'UTC',
+            'format': '%Y-%m-%dT%H:%M:%S.%f',
+            'offset': '1D',
+        }
+        assert task.get_templates() == []
+
+        task = tasks[1]
+        assert isinstance(task, DateTask)
+        assert task.variable == 'date2'
+        assert task.value == '{{ AtomicDate.test }}'
+        assert task.arguments == {
+            'offset': '-1D',
+        }
+        assert task.get_templates() == ['{{ AtomicDate.test }}'], f'{str(task.get_templates())}'
+
+        task = tasks[2]
+        assert isinstance(task, DateTask)
+        assert task.variable == 'date3'
+        assert task.value == '{{ datetime.now() }}'
+        assert task.arguments == {
+            'offset': '1Y',
+            'timezone': '{{ timezone }}',
+        }
+        assert sorted(task.get_templates()) == sorted([
+            '{{ datetime.now() }}',
+            '{{ timezone }}',
+        ]), str(task.get_templates())
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_date_task)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'And value for variable "date1" is "none"',
+            'And value for variable "date2" is "none"',
+            'And value for variable "date3" is "none"',
+            'And value for variable "AtomicDate.test" is "now"',
+            'Then parse date "2022-01-17 12:21:37 | timezone=UTC, format="%Y-%m-%dT%H:%M:%S.%f", offset=1D" and save in variable "date1"',
+            'Then parse date "{{ AtomicDate.test }} | offset=-1D" and save in variable "date2"',
+            'Then parse date "{{ datetime.now() }} | offset=1Y, timezone=\"{{ timezone }}\"" and save in variable "date3"',
+        ]
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+def test_e2e_step_async_group(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_async_group(context: Context) -> None:
+        from json import loads as jsonloads
+        from grizzly.types import RequestMethod
+        from grizzly.tasks import AsyncRequestGroupTask, RequestTask
+        from jinja2 import Template
+
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        assert grizzly.scenario.async_group is None
+
+        tasks = grizzly.scenario.tasks
+        tasks.pop()
+
+        assert len(tasks) == 2
+
+        task = tasks[0]
+        print(type(task))
+        assert isinstance(task, AsyncRequestGroupTask)
+        print(task.__template_attributes__)
+        assert sorted(task.get_templates()) == sorted([
+            'async-group-{{ index }}',
+            'async-group-{{ index }}:test-post-1',
+            'async-group-{{ index }}:test-get-1',
+        ]), str(task.get_templates())
+        assert task.name == 'async-group-{{ index }}'
+        assert len(task.requests) == 2
+
+        request = task.requests[0]
+        assert isinstance(request, RequestTask)
+        assert request.method == RequestMethod.POST
+        assert request.name == 'async-group-{{ index }}:test-post-1'
+        assert request.endpoint == '/api/test'
+        assert isinstance(request.template, Template)
+        assert request.source is not None
+        assert jsonloads(request.source) == {'value': 'i have good news!'}
+        assert request.get_templates() == ['async-group-{{ index }}:test-post-1']
+
+        request = task.requests[1]
+        assert isinstance(request, RequestTask)
+        assert request.method == RequestMethod.GET
+        assert request.name == 'async-group-{{ index }}:test-get-1'
+        assert request.endpoint == '/api/test'
+        assert request.template is None
+        assert request.source is None
+        assert request.get_templates() == ['async-group-{{ index }}:test-get-1']
+
+        task = tasks[1]
+        assert isinstance(task, RequestTask)
+        assert task.method == RequestMethod.GET
+        assert task.name == 'test-get-2'
+        assert task.endpoint == '/api/test'
+        assert task.source is None
+        assert task.template is None
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_async_group)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'Given an async request group with name "async-group-{{ index }}"',
+            dedent('''Then post request with name "test-post-1" to endpoint "/api/test"
+                """
+                {
+                    "value": "i have good news!"
+                }
+                """
+            '''),
+            'Then get request with name "test-get-1" from endpoint "/api/test"',
+            'And close async request group',
+            'Then get request with name "test-get-2" from endpoint "/api/test"',
+        ]
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
diff --git a/tests/e2e/steps/scenario/test_user.py b/tests/e2e/steps/scenario/test_user.py
new file mode 100644
index 00000000..4f9734c2
--- /dev/null
+++ b/tests/e2e/steps/scenario/test_user.py
@@ -0,0 +1,99 @@
+from typing import cast, List, Dict
+from random import randint
+
+import pytest
+
+from behave.runner import Context
+from grizzly.context import GrizzlyContext
+
+from ....fixtures import BehaveContextFixture
+
+
+@pytest.mark.parametrize('user_type,host', [
+    ('RestApi', 'https://api.example.com',),
+    ('MessageQueueUser', 'mq://mqm:secret@mq.example.com/?QueueManager=QMGR01&Channel=Channel01',),
+    ('ServiceBus', 'sb://sb.example.com/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123def456ghi789=',),
+    ('BlobStorageUser', 'DefaultEndpointsProtocol=https;EndpointSuffix=core.windows.net;AccountName=examplestorage;AccountKey=xxxyyyyzzz==',),
+    ('Sftp', 'sftp://ftp.example.com',),
+])
+def test_e2e_step_user_type_with_weight(behave_context_fixture: BehaveContextFixture, user_type: str, host: str) -> None:
+    def validate_user_type(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        expected_weight = int(data['weight'])
+        expected_user_type = data['user_type']
+        expected_host = data['host']
+
+        if not expected_user_type.endswith('User'):
+            expected_user_type += 'User'
+
+        assert grizzly.scenario.user.class_name == expected_user_type
+        assert grizzly.scenario.user.weight == expected_weight
+        assert grizzly.scenario.context.get('host', None) == expected_host
+
+        raise SystemExit(0)
+
+    weight = randint(1, 100)
+
+    table: List[Dict[str, str]] = [{
+        'user_type': user_type,
+        'weight': str(weight),
+        'host': host,
+    }]
+
+    behave_context_fixture.add_validator(validate_user_type, table=table)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            f'Given a user of type "{user_type}" with weight "{weight}" load testing "{host}"',
+        ],
+        identifier=user_type,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
+
+
+@pytest.mark.parametrize('user_type,host', [
+    ('RestApi', 'https://api.example.com',),
+    ('MessageQueueUser', 'mq://mqm:secret@mq.example.com/?QueueManager=QMGR01&Channel=Channel01',),
+    ('ServiceBus', 'sb://sb.example.com/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123def456ghi789=',),
+    ('BlobStorageUser', 'DefaultEndpointsProtocol=https;EndpointSuffix=core.windows.net;AccountName=examplestorage;AccountKey=xxxyyyyzzz==',),
+    ('Sftp', 'sftp://ftp.example.com',),
+])
+def test_e2e_step_user_type(behave_context_fixture: BehaveContextFixture, user_type: str, host: str) -> None:
+    def validate_user_type(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+        data = list(context.table)[0].as_dict()
+
+        expected_user_type = data['user_type']
+        expected_host = data['host']
+
+        if not expected_user_type.endswith('User'):
+            expected_user_type += 'User'
+
+        assert grizzly.scenario.user.class_name == expected_user_type
+        assert grizzly.scenario.user.weight == 1
+        assert grizzly.scenario.context.get('host', None) == expected_host
+
+        raise SystemExit(0)
+
+    table: List[Dict[str, str]] = [{
+        'user_type': user_type,
+        'host': host,
+    }]
+
+    behave_context_fixture.add_validator(validate_user_type, table=table)
+
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            f'Given a user of type "{user_type}" load testing "{host}"',
+        ],
+        identifier=user_type,
+    )
+
+    rc, _ = behave_context_fixture.execute(feature_file)
+
+    assert rc == 0
diff --git a/tests/e2e/steps/test_setup.py b/tests/e2e/steps/test_setup.py
new file mode 100644
index 00000000..0439424e
--- /dev/null
+++ b/tests/e2e/steps/test_setup.py
@@ -0,0 +1,41 @@
+from typing import cast
+
+from behave.runner import Context
+from grizzly.context import GrizzlyContext
+
+from ...fixtures import BehaveContextFixture
+
+
+def test_e2e_step_setup_variable_value_ask(behave_context_fixture: BehaveContextFixture) -> None:
+    def validate_variables(context: Context) -> None:
+        grizzly = cast(GrizzlyContext, context.grizzly)
+
+        assert grizzly.state.variables['background_variable'] == 'foo-background-value'
+        assert grizzly.state.variables['scenario_variable'] == 'bar-scenario-value'
+
+        raise SystemExit(0)
+
+    behave_context_fixture.add_validator(validate_variables)
+
+    feature_file = behave_context_fixture.test_steps(
+        background=[
+            'And ask for value of variable "background_variable"'
+        ],
+        scenario=[
+            'Then ask for value of variable "scenario_variable"'
+        ]
+    )
+
+    assert feature_file == 'features/test_e2e_step_setup_variable_value_ask.feature'
+
+    rc, output = behave_context_fixture.execute(feature_file, testdata={
+        'background_variable': 'foo-background-value',
+        'scenario_variable': 'bar-scenario-value',
+    })
+
+    try:
+        assert rc == 0
+    except AssertionError:
+        print(''.join(output))
+
+        raise
diff --git a/tests/e2e/steps/test_utils.py b/tests/e2e/steps/test_utils.py
new file mode 100644
index 00000000..7a77ccf0
--- /dev/null
+++ b/tests/e2e/steps/test_utils.py
@@ -0,0 +1,20 @@
+from ...fixtures import BehaveContextFixture
+
+
+def test_e2e_step_utils_fail(behave_context_fixture: BehaveContextFixture) -> None:
+    feature_file = behave_context_fixture.test_steps(
+        scenario=[
+            'Then fail'
+        ]
+    )
+
+    assert feature_file == 'features/test_e2e_step_utils_fail.feature'
+
+    rc, output = behave_context_fixture.execute(feature_file)
+    result = ''.join(output)
+
+    assert rc == 1
+    assert 'Then print message "dummy"                                       # None' in result
+    assert '''0 features passed, 1 failed, 0 skipped
+0 scenarios passed, 1 failed, 0 skipped
+3 steps passed, 1 failed, 1 skipped, 0 undefined''' in result
diff --git a/tests/e2e/test_example.py b/tests/e2e/test_example.py
new file mode 100644
index 00000000..ac7948a6
--- /dev/null
+++ b/tests/e2e/test_example.py
@@ -0,0 +1,48 @@
+from tempfile import NamedTemporaryFile
+from os import chdir, getcwd
+from typing import Optional
+
+import yaml
+
+from ..fixtures import Webserver
+from ..helpers import run_command
+
+
+def test_e2e_example(webserver: Webserver) -> None:
+    cwd = getcwd()
+
+    try:
+        result: Optional[str] = None
+        with open('example/environments/example.yaml') as env_yaml_file:
+            env_conf = yaml.full_load(env_yaml_file)
+
+            for name in ['dog', 'cat', 'book']:
+                env_conf['configuration']['facts'][name]['host'] = f'http://127.0.0.1:{webserver._web_server.server_port}'
+
+        with NamedTemporaryFile(delete=True, suffix='.yaml') as env_conf_file:
+            chdir('example/')
+            env_conf_file.write(yaml.dump(env_conf, Dumper=yaml.Dumper).encode())
+            env_conf_file.flush()
+
+            code, output = run_command([
+                'grizzly-cli',
+                'local', 'run',
+                '--yes',
+                '-e', env_conf_file.name,
+                'features/example.feature'
+            ])
+
+            result = ''.join(output)
+
+            assert code == 0
+            assert 'ERROR' not in result
+            assert 'WARNING' not in result
+            assert '1 feature passed, 0 failed, 0 skipped' in result
+            assert '3 scenarios passed, 0 failed, 0 skipped' in result
+            assert '21 steps passed, 0 failed, 0 skipped, 0 undefined' in result
+    except:
+        if result is not None:
+            print(result)
+        raise
+    finally:
+        chdir(cwd)
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 72ece44f..c3ad46fc 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -1,15 +1,22 @@
 import pkgutil
 import inspect
 import socket
+import csv
+import re
 
 from typing import TYPE_CHECKING, Optional, Union, Callable, Any, Literal, List, Tuple, Type, Dict, cast
 from types import TracebackType
 from unittest.mock import MagicMock
 from urllib.parse import urlparse
 from mypy_extensions import VarArg, KwArg
-from os import environ, path
+from os import chdir, environ, getcwd, path
 from shutil import rmtree
 from json import dumps as jsondumps
+from pathlib import Path
+from textwrap import dedent, indent
+from hashlib import sha1
+
+import gevent
 
 from locust.clients import ResponseContextManager
 from locust.contrib.fasthttp import FastResponse
@@ -26,6 +33,7 @@
 from behave.configuration import Configuration
 from behave.step_registry import registry as step_registry
 from requests.models import CaseInsensitiveDict, Response, PreparedRequest
+from flask import Flask, request, jsonify, Response as FlaskResponse
 
 from grizzly.types import GrizzlyResponseContextManager, RequestMethod
 from grizzly.tasks.request import RequestTask
@@ -35,6 +43,7 @@
 from grizzly.context import GrizzlyContext, GrizzlyContextScenario
 
 from .helpers import TestUser, TestScenario, RequestSilentFailureEvent
+from .helpers import onerror, run_command
 
 if TYPE_CHECKING:
     from grizzly.users.base import GrizzlyUser
@@ -498,6 +507,88 @@ def get_code(self) -> int:
         return response_context_manager
 
 
+app = Flask(__name__)
+root_dir = path.realpath(path.join(path.dirname(__file__), '..'))
+
+
+@app.route('/api/v1/resources/dogs')
+def get_dog_fact() -> FlaskResponse:
+    _ = int(request.args.get('number', ''))
+
+    return jsonify(['woof woof wooof'])
+
+
+@app.route('/facts')
+def get_cat_fact() -> FlaskResponse:
+    _ = int(request.args.get('limit', ''))
+
+    return jsonify(['meow meow meow'])
+
+
+@app.route('/books/<book>.json')
+def get_book(book: str) -> FlaskResponse:
+    with open(f'{root_dir}/example/features/requests/books/books.csv', 'r') as fd:
+        reader = csv.DictReader(fd)
+        for row in reader:
+            if row['book'] == book:
+                return jsonify({
+                    'number_of_pages': row['pages'],
+                    'isbn_10': [row['isbn_10']] * 2,
+                    'authors': [
+                        {'key': '/author/' + row['author'].replace(' ', '_').strip() + '|' + row['isbn_10'].strip()},
+                    ]
+                })
+
+
+@app.route('/author/<author_key>.json')
+def get_author(author_key: str) -> FlaskResponse:
+    name, _ = author_key.rsplit('|', 1)
+
+    return jsonify({
+        'name': name.replace('_', ' ')
+    })
+
+
+class Webserver:
+    _web_server: gevent.pywsgi.WSGIServer
+    port: int
+
+    def __init__(self) -> None:
+        self._web_server = gevent.pywsgi.WSGIServer(
+            ('127.0.0.1', 0),
+            app,
+            log=None,
+        )
+
+    def __enter__(self) -> 'Webserver':
+        gevent.spawn(lambda: self._web_server.serve_forever())
+        gevent.sleep(0.01)
+        self.port = self._web_server.server_port
+
+        return self
+
+    def __exit__(
+        self,
+        exc_type: Optional[Type[BaseException]],
+        exc: Optional[BaseException],
+        traceback: Optional[TracebackType],
+    ) -> Literal[True]:
+        self._web_server.stop_accepting()
+        self._web_server.stop()
+
+        try:
+            del environ['GRIZZLY_CONTEXT_ROOT']
+        except KeyError:
+            pass
+
+        try:
+            GrizzlyContext.destroy()
+        except:
+            pass
+
+        return True
+
+
 class NoopZmqFixture:
     _mocker: MockerFixture
 
@@ -549,3 +640,271 @@ def get_mock(self, attr: str) -> MagicMock:
                 return mock
 
         raise AttributeError(f'no mocks for {attr}')
+
+
+BehaveKeyword = Literal['Then', 'Given', 'And', 'When']
+
+
+class BehaveValidator:
+    name: str
+    implementation: Any
+    table: Optional[List[Dict[str, str]]]
+
+    def __init__(
+        self,
+        name: str,
+        implementation: Callable[[BehaveContext], None],
+        table: Optional[List[Dict[str, str]]] = None,
+    ) -> None:
+        self.name = name
+        self.implementation = implementation
+        self.table = table
+
+    @property
+    def expression(self) -> str:
+        lines: List[str] = [f'Then run validator {self.name}_{self.implementation.__name__}']
+        if self.table is not None and len(self.table) > 0:
+            lines.append(f'  | {" | ".join([key for key in self.table[0].keys()])} |')
+
+            for row in self.table:
+                lines.append(f'  | {" | ".join([value for value in row.values()])} |')
+
+        return '\n'.join(lines)
+
+    @property
+    def impl(self) -> str:
+        source_lines = inspect.getsource(self.implementation).split('\n')
+        source_lines[0] = source_lines[0].replace('def ', f'def {self.name}_')
+        source = '\n'.join(source_lines)
+
+        return f'''@then(u'run validator {self.name}_{self.implementation.__name__}')
+{dedent(source)}
+'''
+
+
+class BehaveContextFixture:
+    _tmp_path_factory: TempPathFactory
+    _cwd: str
+    _env: Dict[str, str]
+    _validators: List[BehaveValidator]
+
+    _root: Optional[Path]
+
+    def __init__(self, tmp_path_factory: TempPathFactory) -> None:
+        self._tmp_path_factory = tmp_path_factory
+        self._cwd = getcwd()
+        self._env = {}
+        self._validators = []
+        self._root = None
+
+    @property
+    def root(self) -> Path:
+        if self._root is None:
+            raise AttributeError('root is not set')
+
+        return self._root
+
+    def __enter__(self) -> 'BehaveContextFixture':
+        test_context = self._tmp_path_factory.mktemp('test_context')
+
+        # create grizzly project
+        rc, output = run_command(
+            ['grizzly-cli', 'init', '--yes', 'test-project'],
+            cwd=str(test_context),
+        )
+
+        try:
+            assert rc == 0
+        except AssertionError:
+            print(''.join(output))
+            raise
+
+        self._root = test_context / 'test-project'
+
+        assert self._root.is_dir()
+
+        (self._root / 'features' / 'test-project.feature').unlink()
+
+        # create base steps.py
+        with open(self._root / 'features' / 'steps' / 'steps.py', 'w') as fd:
+            fd.write('from typing import cast\n\n')
+            fd.write('from behave import then\n')
+            fd.write('from behave.runner import Context\n')
+            fd.write('from grizzly.context import GrizzlyContext\n')
+            fd.write('from grizzly.steps import *\n')
+
+        # create virtualenv
+        rc, output = run_command(
+            ['python3', '-m', 'venv', '.virtual-env'],
+            cwd=str(self._root),
+        )
+
+        try:
+            assert rc == 0
+        except AssertionError:
+            print(''.join(output))
+
+            raise
+
+        path = environ.get('PATH', '')
+
+        virtual_env_path = self._root / '.virtual-env'
+
+        self._env.update({
+            'PATH': f'{str(virtual_env_path)}/bin:{path}',
+            'VIRTUAL_ENV': str(virtual_env_path),
+            'PYTHONPATH': environ.get('PYTHONPATH', '.'),
+        })
+
+        # install dependencies
+        rc, output = run_command(
+            ['python3', '-m', 'pip', 'install', '-r', 'requirements.txt'],
+            cwd=str(self._root),
+            env=self._env,
+        )
+
+        return self
+
+    def __exit__(
+        self,
+        exc_type: Optional[Type[BaseException]],
+        exc: Optional[BaseException],
+        traceback: Optional[TracebackType],
+    ) -> Literal[True]:
+
+        if environ.get('KEEP_FILES', None) is None:
+            try:
+                rmtree(self.root.parent.parent, onerror=onerror)
+            except AttributeError:
+                pass
+
+        return True
+
+    def add_validator(
+        self,
+        implementation: Callable[[BehaveContext], None],
+        /,
+        table: Optional[List[Dict[str, str]]] = None,
+    ) -> None:
+        callee = inspect.stack()[1].function
+        self._validators.append(BehaveValidator(callee, implementation, table))
+
+    def test_steps(self, /, scenario: Optional[List[str]] = None, background: Optional[List[str]] = None, identifier: Optional[str] = None) -> str:
+        callee = inspect.stack()[1].function
+        contents: List[str] = ['Feature:']
+        add_user_count_step = True
+        add_user_type_step = True
+        add_spawn_rate_step = True
+
+        if background is None:
+            background = []
+
+        if scenario is None:
+            scenario = []
+
+        # check required steps
+        for step in background + scenario:
+            if re.match(r'Given "[^"]*" user[s]?', step) is not None:
+                add_user_count_step = False
+
+            if re.match(r'Given a user of type "[^"]*"', step) is not None:
+                add_user_type_step = False
+
+            if re.match(r'(And|Given) spawn rate is "[^"]*" user[s]? per second', step) is not None:
+                add_spawn_rate_step = False
+
+        if add_user_count_step:
+            background.insert(0, 'Given "1" user')
+            if add_spawn_rate_step:
+                background.insert(1, 'And spawn rate is "1" user per second')
+
+        if add_user_type_step:
+            scenario.insert(0, 'Given a user of type "RestApi" load testing "http://localhost:1"')
+
+        if add_spawn_rate_step and not add_user_count_step:
+            background.append('And spawn rate is "1" user per second')
+
+        contents.append('  Background: common configuration')
+        for step in background:
+            contents.append(f'    {step}')
+
+        contents.append('')
+
+        if scenario is not None:
+            contents.append(f'  Scenario: {callee}')
+            for step in scenario or []:
+                contents.append(f'    {step}')
+            contents.append('    Then print message "dummy"')
+
+        contents.append('')
+
+        return self.create_feature(
+            '\n'.join(contents),
+            name=callee,
+            identifier=identifier,
+        )
+
+    def create_feature(self, contents: str, name: Optional[str] = None, identifier: Optional[str] = None) -> str:
+        if name is None:
+            name = inspect.stack()[1].function
+
+        if identifier is not None:
+            identifier = sha1(identifier.encode()).hexdigest()[:8]
+            name = f'{name}_{identifier}'
+
+        with open(self.root / 'features' / f'{name}.feature', 'w+') as ffd:
+            feature_lines = contents.split('\n')
+            feature_lines[0] = f'Feature: test {name}'
+            contents = '\n'.join(feature_lines)
+
+            ffd.write(contents)
+
+            steps_file = self.root / 'features' / 'steps' / 'steps.py'
+            with open(steps_file, 'r') as sfd:
+                steps_impl = sfd.read()
+
+            with open(steps_file, 'a') as sfd:
+                for validator in self._validators:
+                    # write step to feature file
+                    ffd.write(indent(f'{validator.expression}\n', prefix='    '))
+
+                    # write expression and step implementation to steps/steps.py
+                    if validator.impl not in steps_impl:
+                        sfd.write(f'\n\n{validator.impl}')
+
+            # they are now "burned"...
+            self._validators.clear()
+
+            return ffd.name.replace(f'{self.root}/', '')
+
+    def execute(self, feature_file: str, env_conf_file: Optional[str] = None, testdata: Optional[Dict[str, str]] = None) -> Tuple[int, List[str]]:
+        chdir(self.root)
+
+        command = [
+            'grizzly-cli',
+            'local',
+            'run',
+            '--yes',
+            '--verbose',
+            feature_file,
+        ]
+
+        if env_conf_file is not None:
+            command += ['-e', env_conf_file]
+
+        if testdata is not None:
+            for key, value in testdata.items():
+                command += ['-T', f'{key}={value}']
+
+        rc, output = run_command(
+            command,
+            cwd=str(self.root),
+            env=self._env,
+        )
+
+        if rc != 0:
+            print(''.join(output))
+
+        chdir(self._cwd)
+
+        return rc, output
diff --git a/tests/helpers.py b/tests/helpers.py
index 0bd2b544..1d2de5d5 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -1,7 +1,10 @@
 import inspect
+import subprocess
+import os
+import stat
 
-from typing import Any, Dict, Optional, Tuple, List, Set
-from types import MethodType
+from typing import Any, Dict, Optional, Tuple, List, Set, Callable
+from types import MethodType, TracebackType
 
 from locust import task
 from locust.event import EventHook
@@ -111,3 +114,61 @@ def get_property_decorated_attributes(target: Any) -> Set[str]:
                 )) if not name.startswith('_')
         ]
     )
+
+
+def run_command(command: List[str], env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None) -> Tuple[int, List[str]]:
+    output: List[str] = []
+    if env is None:
+        env = os.environ.copy()
+
+    if cwd is None:
+        cwd = os.getcwd()
+
+    process = subprocess.Popen(
+        command,
+        env=env,
+        cwd=cwd,
+        stderr=subprocess.STDOUT,
+        stdout=subprocess.PIPE,
+    )
+
+    try:
+        while process.poll() is None:
+            stdout = process.stdout
+            if stdout is None:
+                break
+
+            buffer = stdout.readline()
+            if not buffer:
+                break
+
+            output.append(buffer.decode('utf-8'))
+
+        process.terminate()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        try:
+            process.kill()
+        except Exception:
+            pass
+
+    process.wait()
+
+    return process.returncode, output
+
+
+def onerror(func: Callable, path: str, exc_info: TracebackType) -> None:
+    '''
+    Error handler for ``shutil.rmtree``.
+    If the error is due to an access error (read only file)
+    it attempts to add write permission and then retries.
+    If the error is for another reason it re-raises the error.
+    Usage : ``shutil.rmtree(path, onerror=onerror)``
+    '''
+    # Is the error an access error?
+    if not os.access(path, os.W_OK):
+        os.chmod(path, stat.S_IWUSR)
+        func(path)
+    else:
+        raise  # pylint: disable=misplaced-bare-raise
diff --git a/tests/test_grizzly/scenarios/test_iterator.py b/tests/test_grizzly/scenarios/test_iterator.py
index 6263d01e..2024ca91 100644
--- a/tests/test_grizzly/scenarios/test_iterator.py
+++ b/tests/test_grizzly/scenarios/test_iterator.py
@@ -15,7 +15,7 @@
 from grizzly.scenarios.iterator import IteratorScenario
 from grizzly.testdata.communication import TestdataConsumer
 from grizzly.testdata.utils import transform
-from grizzly.tasks import WaitTask, PrintTask
+from grizzly.tasks import WaitTask, LogMessage
 from grizzly.exceptions import RestartScenario
 
 from ...fixtures import GrizzlyFixture
@@ -60,7 +60,7 @@ def mocked_wait(time: float) -> None:
         assert callable(task_method)
         task_method(scenario)
 
-        IteratorScenario.populate(PrintTask(message='hello {{ world }}'))
+        IteratorScenario.populate(LogMessage(message='hello {{ world }}'))
         assert len(scenario.tasks) == 4
 
         logger_spy = mocker.spy(scenario.logger, 'info')
diff --git a/tests/test_grizzly/steps/background/test_shapes.py b/tests/test_grizzly/steps/background/test_shapes.py
index 33f74468..d09d0be2 100644
--- a/tests/test_grizzly/steps/background/test_shapes.py
+++ b/tests/test_grizzly/steps/background/test_shapes.py
@@ -60,6 +60,10 @@ def test_step_shapes_user_count(behave_fixture: BehaveFixture) -> None:
     step_impl(behave, '{{ user_count * 0.1 }}', grammar='user')
     assert grizzly.setup.user_count == 1
 
+    with pytest.raises(AssertionError) as ae:
+        step_impl(behave, '$conf::user.count', grammar='users')
+    assert 'this expression does not support $conf or $env variables' == str(ae.value)
+
 
 def test_step_shapes_spawn_rate(behave_fixture: BehaveFixture) -> None:
     step_impl = step_shapes_spawn_rate
@@ -95,3 +99,7 @@ def test_step_shapes_spawn_rate(behave_fixture: BehaveFixture) -> None:
 
     step_impl(behave, '{{ spawn_rate / 1000 }}', grammar='users')
     assert grizzly.setup.spawn_rate == 0.01
+
+    with pytest.raises(AssertionError) as ae:
+        step_impl(behave, '$conf::user.rate', grammar='users')
+    assert 'this expression does not support $conf or $env variables' == str(ae.value)
diff --git a/tests/test_grizzly/steps/scenario/test_tasks.py b/tests/test_grizzly/steps/scenario/test_tasks.py
index cdaf9951..e863b736 100644
--- a/tests/test_grizzly/steps/scenario/test_tasks.py
+++ b/tests/test_grizzly/steps/scenario/test_tasks.py
@@ -8,7 +8,7 @@
 from behave.model import Table, Row
 from grizzly.context import GrizzlyContext
 from grizzly.types import RequestMethod, RequestDirection
-from grizzly.tasks import TransformerTask, PrintTask, WaitTask
+from grizzly.tasks import TransformerTask, LogMessage, WaitTask
 from grizzly.tasks.clients import HttpClientTask
 from grizzly.steps import *  # pylint: disable=unused-wildcard-import  # noqa: F403
 
@@ -95,8 +95,11 @@ def test_step_task_request_with_name_to_endpoint_until(behave_fixture: BehaveFix
     assert tasks[-3].condition == '$.`this`[?status="{{ variable }}"]'
     templates += tasks[-3].get_templates()
 
-    assert len(templates) == 1
-    assert templates[-1] == '$.`this`[?status="{{ variable }}"]'
+    assert len(templates) == 2
+    assert sorted(templates) == sorted([
+        '$.`this`[?status="{{ variable }}"]',
+        '/api/{{ variable }}',
+    ])
 
 
 @pytest.mark.parametrize('method', RequestDirection.TO.methods)
@@ -216,7 +219,7 @@ def test_step_task_print_message(behave_fixture: BehaveFixture) -> None:
 
     step_task_print_message(behave, 'hello {{ world }}')
 
-    assert isinstance(grizzly.scenario.tasks[-1], PrintTask)
+    assert isinstance(grizzly.scenario.tasks[-1], LogMessage)
     assert grizzly.scenario.tasks[-1].message == 'hello {{ world }}'
 
 
diff --git a/tests/test_grizzly/tasks/test_async_group.py b/tests/test_grizzly/tasks/test_async_group.py
index 2e895b24..51205b28 100644
--- a/tests/test_grizzly/tasks/test_async_group.py
+++ b/tests/test_grizzly/tasks/test_async_group.py
@@ -36,6 +36,27 @@ def test_add(self) -> None:
         assert len(task_factory.requests) == 1
         assert task_factory.requests[-1].name == 'test:test'
 
+    @pytest.mark.parametrize('affix', [True, False])
+    def test_get_templates(self, affix: bool) -> None:
+        task_factory = AsyncRequestGroupTask(name='async-{{ name }}')
+        assert len(task_factory.requests) == 0
+
+        name_template = 'test-'
+        if affix:
+            name_template += '{{ name }}-'
+
+        task_factory.add(RequestTask(RequestMethod.GET, name=f'{name_template}-1', endpoint='/api/test'))
+        task_factory.add(RequestTask(RequestMethod.GET, name=f'{name_template}-2', endpoint='/api/test'))
+        task_factory.add(RequestTask(RequestMethod.GET, name=f'{name_template}-3', endpoint='/api/test'))
+
+        assert len(task_factory.requests) == 3
+        assert sorted(task_factory.get_templates()) == sorted([
+            'async-{{ name }}',
+            f'async-{{{{ name }}}}:{name_template}-1',
+            f'async-{{{{ name }}}}:{name_template}-2',
+            f'async-{{{{ name }}}}:{name_template}-3',
+        ])
+
     def test___call__(self, grizzly_fixture: GrizzlyFixture, mocker: MockerFixture, caplog: LogCaptureFixture) -> None:
         _, _, scenario = grizzly_fixture()
 
diff --git a/tests/test_grizzly/tasks/test_print.py b/tests/test_grizzly/tasks/test_log_message.py
similarity index 82%
rename from tests/test_grizzly/tasks/test_print.py
rename to tests/test_grizzly/tasks/test_log_message.py
index 90a41a10..02a4eade 100644
--- a/tests/test_grizzly/tasks/test_print.py
+++ b/tests/test_grizzly/tasks/test_log_message.py
@@ -2,14 +2,14 @@
 
 from _pytest.logging import LogCaptureFixture
 
-from grizzly.tasks import PrintTask
+from grizzly.tasks import LogMessage
 
 from ...fixtures import GrizzlyFixture
 
 
-class TestPrintTask:
+class TestLogMessage:
     def test(self, grizzly_fixture: GrizzlyFixture, caplog: LogCaptureFixture) -> None:
-        task_factory = PrintTask(message='hello world!')
+        task_factory = LogMessage(message='hello world!')
         assert task_factory.message == 'hello world!'
 
         task = task_factory()
@@ -25,7 +25,7 @@ def test(self, grizzly_fixture: GrizzlyFixture, caplog: LogCaptureFixture) -> No
         assert 'hello world!' in caplog.text
         caplog.clear()
 
-        task_factory = PrintTask(message='variable={{ variable }}')
+        task_factory = LogMessage(message='variable={{ variable }}')
         assert task_factory.message == 'variable={{ variable }}'
 
         task = task_factory()
diff --git a/tests/test_grizzly/test_context.py b/tests/test_grizzly/test_context.py
index 90dfc542..26fd234f 100644
--- a/tests/test_grizzly/test_context.py
+++ b/tests/test_grizzly/test_context.py
@@ -20,7 +20,7 @@
 )
 
 from grizzly.types import RequestMethod
-from grizzly.tasks import PrintTask, RequestTask, WaitTask
+from grizzly.tasks import LogMessage, RequestTask, WaitTask
 
 
 from ..helpers import get_property_decorated_attributes
@@ -319,6 +319,6 @@ def test_tasks(self, request_task: RequestTaskFixture) -> None:
         scenario.add_task(wait_task)
         assert scenario.tasks == [request, second_request, wait_task]
 
-        print_task = PrintTask(message='hello general')
-        scenario.add_task(print_task)
-        assert scenario.tasks == [request, second_request, wait_task, print_task]
+        log_task = LogMessage(message='hello general')
+        scenario.add_task(log_task)
+        assert scenario.tasks == [request, second_request, wait_task, log_task]
diff --git a/tests/test_grizzly/test_locust.py b/tests/test_grizzly/test_locust.py
index f19d7623..22b549a9 100644
--- a/tests/test_grizzly/test_locust.py
+++ b/tests/test_grizzly/test_locust.py
@@ -28,7 +28,7 @@
 )
 from grizzly.types import RequestMethod
 from grizzly.context import GrizzlyContext, GrizzlyContextScenario
-from grizzly.tasks import GrizzlyTask, PrintTask, RequestTask, WaitTask
+from grizzly.tasks import GrizzlyTask, LogMessage, RequestTask, WaitTask
 from grizzly.users import RestApiUser, MessageQueueUser
 from grizzly.users.base import GrizzlyUser
 from grizzly.scenarios import IteratorScenario
@@ -174,7 +174,7 @@ def test_setup_locust_scenarios(behave_fixture: BehaveFixture) -> None:
     task = RequestTask(RequestMethod.GET, 'test-1', '/api/v1/test/1')
     grizzly.scenario.add_task(task)
     grizzly.scenario.add_task(WaitTask(time=1.5))
-    grizzly.scenario.add_task(PrintTask(message='test message'))
+    grizzly.scenario.add_task(LogMessage(message='test message'))
 
     # incorrect user type
     grizzly.scenario.user.class_name = 'NonExistingUser'
@@ -239,7 +239,7 @@ def test_setup_locust_scenarios_user_distribution(behave_fixture: BehaveFixture,
         scenario = Scenario(filename=None, line=None, keyword='', name=f'Test-{(index + 1)}')
         grizzly.add_scenario(scenario)
         grizzly.scenario.context['host'] = 'http://localhost:8003'
-        grizzly.scenario.add_task(PrintTask(message='foo bar'))
+        grizzly.scenario.add_task(LogMessage(message='foo bar'))
         grizzly.scenario.user.class_name = 'RestApiUser'
         grizzly.scenario.user.weight = distribution[index]
 
diff --git a/tests/test_grizzly/testdata/test_ast.py b/tests/test_grizzly/testdata/test_ast.py
index ae6e8f45..ab7c57f0 100644
--- a/tests/test_grizzly/testdata/test_ast.py
+++ b/tests/test_grizzly/testdata/test_ast.py
@@ -4,7 +4,7 @@
 from grizzly.testdata.ast import _parse_templates, get_template_variables
 from grizzly.types import RequestMethod
 from grizzly.context import GrizzlyContextScenario
-from grizzly.tasks import RequestTask, PrintTask
+from grizzly.tasks import RequestTask, LogMessage
 
 from ...fixtures import RequestTaskFixture
 
@@ -63,7 +63,7 @@ def test_get_template_variables() -> None:
     task.source = '{{ AtomicIntegerIncrementer.test }}'
 
     scenario.add_task(
-        PrintTask(message='{{ foo }}')
+        LogMessage(message='{{ foo }}')
     )
 
     variables = get_template_variables(scenario.tasks)
diff --git a/tests/test_grizzly/testdata/test_communication.py b/tests/test_grizzly/testdata/test_communication.py
index 17225ad6..f0928c37 100644
--- a/tests/test_grizzly/testdata/test_communication.py
+++ b/tests/test_grizzly/testdata/test_communication.py
@@ -18,7 +18,7 @@
 from grizzly.testdata.communication import TestdataConsumer, TestdataProducer
 from grizzly.testdata.utils import initialize_testdata, transform
 from grizzly.context import GrizzlyContext
-from grizzly.tasks import PrintTask
+from grizzly.tasks import LogMessage
 
 from ...fixtures import AtomicVariableCleanupFixture, LocustFixture, BehaveFixture, GrizzlyFixture, NoopZmqFixture
 
@@ -98,7 +98,7 @@ def noop(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> Any:
             request.source = json.dumps(source)
 
             grizzly.scenario.add_task(request)
-            grizzly.scenario.add_task(PrintTask(message='hello {{ world }}'))
+            grizzly.scenario.add_task(LogMessage(message='hello {{ world }}'))
 
             testdata, external_dependencies = initialize_testdata(grizzly.scenario.tasks)
 
@@ -217,7 +217,7 @@ def test_run_variable_none(
             grizzly.scenario.user.class_name = 'TestUser'
             grizzly.scenario.context['host'] = 'http://test.nu'
             grizzly.scenario.add_task(request)
-            grizzly.scenario.add_task(PrintTask(message='are you {{ sure }}'))
+            grizzly.scenario.add_task(LogMessage(message='are you {{ sure }}'))
 
             testdata, external_dependencies = initialize_testdata(grizzly.scenario.tasks)
 
diff --git a/tests/test_grizzly/testdata/test_utils.py b/tests/test_grizzly/testdata/test_utils.py
index ffb4bd16..c4d8b32e 100644
--- a/tests/test_grizzly/testdata/test_utils.py
+++ b/tests/test_grizzly/testdata/test_utils.py
@@ -15,7 +15,7 @@
 from locust.exception import StopUser
 
 from grizzly.context import GrizzlyContext
-from grizzly.tasks import PrintTask, DateTask, TransformerTask, UntilRequestTask
+from grizzly.tasks import LogMessage, DateTask, TransformerTask, UntilRequestTask
 from grizzly.testdata.utils import (
     _get_variable_value,
     initialize_testdata,
@@ -181,7 +181,7 @@ def test_initialize_testdata_with_tasks(
         request.response.content_type = TransformerContentType.JSON
         scenario = request.scenario
         scenario.add_task(request)
-        scenario.add_task(PrintTask(message='{{ message }}'))
+        scenario.add_task(LogMessage(message='{{ message }}'))
         scenario.add_task(DateTask(variable='date_task', value='{{ date_task_date }} | timezone="{{ timezone }}", offset="-{{ days }}D"'))
         scenario.add_task(TransformerTask(
             expression='$.expression',
diff --git a/tests/test_grizzly_extras/async_message/test___init__.py b/tests/test_grizzly_extras/async_message/test___init__.py
index abed5e6a..686e08de 100644
--- a/tests/test_grizzly_extras/async_message/test___init__.py
+++ b/tests/test_grizzly_extras/async_message/test___init__.py
@@ -1,8 +1,4 @@
-import os
-import stat
-
-from types import TracebackType
-from typing import Optional, Callable, cast
+from typing import Optional, cast
 from os import environ, path, listdir
 from shutil import rmtree
 from platform import node as hostname
@@ -22,25 +18,11 @@
     register,
 )
 
-
-def onerror(func: Callable, path: str, exc_info: TracebackType) -> None:
-    '''
-    Error handler for ``shutil.rmtree``.
-    If the error is due to an access error (read only file)
-    it attempts to add write permission and then retries.
-    If the error is for another reason it re-raises the error.
-    Usage : ``shutil.rmtree(path, onerror=onerror)``
-    '''
-    # Is the error an access error?
-    if not os.access(path, os.W_OK):
-        os.chmod(path, stat.S_IWUSR)
-        func(path)
-    else:
-        raise  # pylint: disable=misplaced-bare-raise
+from ...helpers import onerror
 
 
 class TestAsyncMessageHandler:
-    def test_get_handler(self, mocker: MockerFixture) -> None:
+    def test_get_handler(self) -> None:
         class AsyncMessageTest(AsyncMessageHandler):
             def get_handler(self, action: str) -> Optional[AsyncMessageRequestHandler]:
                 return super().get_handler(action)