From 6eb4f9a9cc66a872c340c870628258773190be98 Mon Sep 17 00:00:00 2001 From: Ludwig Richter Date: Thu, 12 Dec 2024 21:13:13 +0100 Subject: [PATCH 01/21] Setup python cucumber runner --- backend-features/.gitignore | 3 + backend-features/README.md | 22 ++++++ backend-features/requirements.txt | 4 ++ backend-features/steps/steps.py | 102 +++++++++++++++++++++++++++ backend-features/tools/setup-venv.sh | 44 ++++++++++++ 5 files changed, 175 insertions(+) create mode 100644 backend-features/.gitignore create mode 100644 backend-features/requirements.txt create mode 100644 backend-features/steps/steps.py create mode 100755 backend-features/tools/setup-venv.sh diff --git a/backend-features/.gitignore b/backend-features/.gitignore new file mode 100644 index 00000000..135e74ab --- /dev/null +++ b/backend-features/.gitignore @@ -0,0 +1,3 @@ +# python venv +.venv + diff --git a/backend-features/README.md b/backend-features/README.md index 83189426..8e79465d 100644 --- a/backend-features/README.md +++ b/backend-features/README.md @@ -19,3 +19,25 @@ The Gherkin files are located in the `backend-features` directory of the Telesti ## Documentation The Gherkin files get converted to Markdown files that are then included in the documentation. + +## Testing + +### Natively + +Create the Python virtual environment with the helper script: + +```shell +./tools/setup-venv.sh +``` + +Source the virtual environment: + +```shell +. .venv/bin/activate +``` + +Run the tests with: + +```shell +behave +``` diff --git a/backend-features/requirements.txt b/backend-features/requirements.txt new file mode 100644 index 00000000..fe151e39 --- /dev/null +++ b/backend-features/requirements.txt @@ -0,0 +1,4 @@ +# we need this to interact with docker on the system +docker==7.1.0 +# we need this to execute Cucumber tests +behave @ git+https://github.com/behave/behave@v1.2.7.dev6 diff --git a/backend-features/steps/steps.py b/backend-features/steps/steps.py new file mode 100644 index 00000000..d3474158 --- /dev/null +++ b/backend-features/steps/steps.py @@ -0,0 +1,102 @@ +from behave import * +from behave.api.pending_step import StepNotImplementedError +import docker + + +def before_all(context): + context.docker = docker.from_env() + + +# +# nats server setup +# + + +@given(u'I have a NATS server running on "{host}"') +def nats_server(context, host): + raise StepNotImplementedError() + + +@given(u'the NATS server requires authentication') +def nats_server_auth(context): + raise StepNotImplementedError() + + +@given(u'"{username}" is a NATS user with password "{password}"') +def nats_server_user_credentials(context, username, password): + raise StepNotImplementedError() + + +@given(u'the NATS server is offline') +def no_nats_server(context): + raise StepNotImplementedError() + + +# +# service setup +# + + +@given(u'I have the basic service configuration') +def basic_service_configuration(context): + raise StepNotImplementedError() + + +@given(u'I have no service configuration') +def no_service_configuration(context): + raise StepNotImplementedError() + + +@given(u'I have an environment variable named "{key}" with value "{value}"') +def environment_variable(context, key, value): + raise StepNotImplementedError() + + +@when(u'I start the service') +def start_service(context): + raise StepNotImplementedError() + + +@when(u'I start the service without NATS') +def start_service_without_nats(context): + raise StepNotImplementedError() + + +@when(u'I start the service with "{arg}"') +def start_service_with_arg(context, arg): + raise StepNotImplementedError() + + +@when(u'I start the service with "{arg}" without NATS') +def start_service_with_arg_without_nats(context, arg): + raise StepNotImplementedError() + + +@then(u'the service should start') +def assert_service_started(context): + raise StepNotImplementedError() + + +@then(u'the service should connect to NATS') +def assert_service_connected(context): + raise StepNotImplementedError() + + +@then(u'the service should fail to start') +def assert_service_failed(context): + raise StepNotImplementedError() + + +@then(u'the service should be configured with "{key}" set to "{expected_value}"') +def assert_service_configured_with(context, key, expected_value): + raise StepNotImplementedError() + + +@then(u'the NATS connection API should be available to the service') +def assert_nats_api_available(context): + raise StepNotImplementedError() + + +@then(u'the service should not connect to NATS') +def assert_service_not_connected(context): + raise StepNotImplementedError() diff --git a/backend-features/tools/setup-venv.sh b/backend-features/tools/setup-venv.sh new file mode 100755 index 00000000..2009be00 --- /dev/null +++ b/backend-features/tools/setup-venv.sh @@ -0,0 +1,44 @@ +#!/bin/sh + +## This script creates a new and empty virtual python environment (venv) +## in which the needed packages and versions of these packages can be installed. + +VENV_NAME=".venv" + +script_dir="$(dirname "$(realpath "$0")")" +project_dir="$script_dir/.." + +err() { + printf 'err: %s\n' "$@" +} + +if ! command -v "python3" > /dev/null 2>&1; then + err 'This script needs Python 3. Please install Python 3 via your package manager and try again.' + exit 1 +fi + +cd "$project_dir" || { err 'Project directory inaccessible'; exit 1; } + +printf 'Setup virtual environment...\n' +if ! [ -d "$VENV_NAME" ]; then + python3 -m venv "$VENV_NAME" +fi + +# load venv +if ! command -v python3 2> /dev/null | grep "$VENV_NAME/bin/python3" > /dev/null 2>&1; then + # shellcheck disable=SC1091 + . "${VENV_NAME}/bin/activate" +fi + +printf 'Update installer...' +python3 -m pip install --upgrade pip + +printf 'Install required python modules...\n' +pip install -r requirements.txt + +printf '\n----------------------------------------------\n' +printf '\nFinished setting up Ansible environment!\n' +printf 'To use the python environment, source the activate script and you are ready to go:\n\n' +printf '$ source %s/bin/activate\n' "$VENV_NAME" +printf '\n----------------------------------------------\n\n' + From e151ad52df239ec681ff9dbe6e718400b5400b67 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Thu, 12 Dec 2024 21:59:38 +0100 Subject: [PATCH 02/21] Some first steps with the Python Docker API --- backend-features/environment.py | 22 +++++++++++++++ backend-features/steps/steps.py | 50 ++++++++++++++++++++++++++------- 2 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 backend-features/environment.py diff --git a/backend-features/environment.py b/backend-features/environment.py new file mode 100644 index 00000000..1b118e8b --- /dev/null +++ b/backend-features/environment.py @@ -0,0 +1,22 @@ +import uuid + +import docker + + +def before_all(context): + # exit(1) + context.docker = docker.from_env() + context.prefix = generate_prefix() + + +def after_scenario(context, scenario): + for container in context.docker.containers.list(all=True): + if container.name.startswith(context.prefix): + container.remove(force=True) + for network in context.docker.networks.list(): + if network.name.startswith(context.prefix): + network.remove() + + +def generate_prefix(): + return 'telestion-backend-features-' + str(uuid.uuid4()) + '-' diff --git a/backend-features/steps/steps.py b/backend-features/steps/steps.py index d3474158..d1075888 100644 --- a/backend-features/steps/steps.py +++ b/backend-features/steps/steps.py @@ -1,10 +1,5 @@ from behave import * from behave.api.pending_step import StepNotImplementedError -import docker - - -def before_all(context): - context.docker = docker.from_env() # @@ -14,21 +9,45 @@ def before_all(context): @given(u'I have a NATS server running on "{host}"') def nats_server(context, host): - raise StepNotImplementedError() + context.docker.networks.create(context.prefix + 'nats') + context.docker.containers.run( + 'nats:latest', + detach=True, + name=context.prefix + 'nats-server', + ) + context.docker.networks.get(context.prefix + 'nats').connect(context.prefix + 'nats-server') @given(u'the NATS server requires authentication') def nats_server_auth(context): - raise StepNotImplementedError() + context.docker.containers.get(context.prefix + 'nats-server').stop() + context.docker.containers.get(context.prefix + 'nats-server').remove() + context.docker.containers.run( + 'nats:latest', + detach=True, + name=context.prefix + 'nats-server', + environment=['NATS_USER=nats', 'NATS_PASSWORD=nats'], + ) + context.docker.networks.get(context.prefix + 'nats').connect(context.prefix + 'nats-server') @given(u'"{username}" is a NATS user with password "{password}"') def nats_server_user_credentials(context, username, password): - raise StepNotImplementedError() + context.docker.containers.get(context.prefix + 'nats-server').stop() + context.docker.containers.get(context.prefix + 'nats-server').remove() + context.docker.containers.run( + 'nats:latest', + detach=True, + name=context.prefix + 'nats-server', + environment=['NATS_USER=' + username, 'NATS_PASSWORD=' + password], + ) + context.docker.networks.get(context.prefix + 'nats').connect(context.prefix + 'nats-server') @given(u'the NATS server is offline') def no_nats_server(context): + context.docker.containers.get(context.prefix + 'nats-server').stop() + context.docker.containers.get(context.prefix + 'nats-server').remove() raise StepNotImplementedError() @@ -39,7 +58,16 @@ def no_nats_server(context): @given(u'I have the basic service configuration') def basic_service_configuration(context): - raise StepNotImplementedError() + context.environment = { + 'NATS_URL': 'nats://localhost:4222', + 'NATS_USER': 'nats', + 'NATS_PASSWORD': 'nats', + 'SERVICE_NAME': 'my-service', + 'DATA_DIR': '/data', + } + + pass + # raise StepNotImplementedError() @given(u'I have no service configuration') @@ -49,7 +77,9 @@ def no_service_configuration(context): @given(u'I have an environment variable named "{key}" with value "{value}"') def environment_variable(context, key, value): - raise StepNotImplementedError() + if context.environment is None: + context.environment = {} + context.environment[key] = value @when(u'I start the service') From 859478ceabeb97e3ba7bd77384a5636842cd34a1 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Thu, 12 Dec 2024 22:25:13 +0100 Subject: [PATCH 03/21] Further implementations / tests --- backend-features/docker_lib.py | 51 ++++++++++++++++++++++++++++++++ backend-features/environment.py | 11 ++++--- backend-features/steps/steps.py | 52 +++++++++------------------------ 3 files changed, 71 insertions(+), 43 deletions(-) create mode 100644 backend-features/docker_lib.py diff --git a/backend-features/docker_lib.py b/backend-features/docker_lib.py new file mode 100644 index 00000000..72023656 --- /dev/null +++ b/backend-features/docker_lib.py @@ -0,0 +1,51 @@ +from os.path import exists + +import docker + + +def setup_nats(context): + # assert isinstance(context.docker, docker.client.DockerClient) + # assert exists(context.prefix) + + context.docker.networks.create(nats_network_name(context)) + context.docker.containers.run( + 'nats:latest', + detach=True, + name=nats_container_name(context), + ) + nats_network = context.docker.networks.get(nats_network_name(context)) + nats_network.connect(nats_container_name(context)) + + +def nats_container_name(context): + return 'g-' + context.prefix + 'nats-server' + + +def nats_network_name(context): + return 'g-' + context.prefix + 'nats' + + +def teardown_nats(context): + # assert isinstance(context.docker, docker.client.DockerClient) + # assert exists(context.prefix) + + context.docker.containers.get(nats_container_name(context)).stop() + context.docker.containers.get(nats_container_name(context)).remove() + nats_network = context.docker.networks.get(nats_network_name(context)) + nats_network.remove() + + +def nats_online(context): + assert isinstance(context.docker, docker.DockerClient) + + network = context.docker.networks.get(nats_network_name(context)) + if nats_container_name(context) in [container.name for container in context.docker.containers.list()]: + return + + network.connect(nats_container_name(context)) + + +def nats_offline(context): + assert isinstance(context.docker, docker.DockerClient) + + context.docker.networks.get(nats_network_name(context)).disconnect(nats_container_name(context)) diff --git a/backend-features/environment.py b/backend-features/environment.py index 1b118e8b..ac144b02 100644 --- a/backend-features/environment.py +++ b/backend-features/environment.py @@ -1,21 +1,24 @@ import uuid - import docker +from docker_lib import setup_nats, teardown_nats + def before_all(context): # exit(1) context.docker = docker.from_env() context.prefix = generate_prefix() + setup_nats(context) + + +def after_all(context): + teardown_nats(context) def after_scenario(context, scenario): for container in context.docker.containers.list(all=True): if container.name.startswith(context.prefix): container.remove(force=True) - for network in context.docker.networks.list(): - if network.name.startswith(context.prefix): - network.remove() def generate_prefix(): diff --git a/backend-features/steps/steps.py b/backend-features/steps/steps.py index d1075888..34683a31 100644 --- a/backend-features/steps/steps.py +++ b/backend-features/steps/steps.py @@ -1,6 +1,9 @@ +import docker as dockerlib from behave import * from behave.api.pending_step import StepNotImplementedError +from docker_lib import nats_online, nats_offline + # # nats server setup @@ -9,46 +12,22 @@ @given(u'I have a NATS server running on "{host}"') def nats_server(context, host): - context.docker.networks.create(context.prefix + 'nats') - context.docker.containers.run( - 'nats:latest', - detach=True, - name=context.prefix + 'nats-server', - ) - context.docker.networks.get(context.prefix + 'nats').connect(context.prefix + 'nats-server') + nats_online(context) @given(u'the NATS server requires authentication') def nats_server_auth(context): - context.docker.containers.get(context.prefix + 'nats-server').stop() - context.docker.containers.get(context.prefix + 'nats-server').remove() - context.docker.containers.run( - 'nats:latest', - detach=True, - name=context.prefix + 'nats-server', - environment=['NATS_USER=nats', 'NATS_PASSWORD=nats'], - ) - context.docker.networks.get(context.prefix + 'nats').connect(context.prefix + 'nats-server') + pass @given(u'"{username}" is a NATS user with password "{password}"') def nats_server_user_credentials(context, username, password): - context.docker.containers.get(context.prefix + 'nats-server').stop() - context.docker.containers.get(context.prefix + 'nats-server').remove() - context.docker.containers.run( - 'nats:latest', - detach=True, - name=context.prefix + 'nats-server', - environment=['NATS_USER=' + username, 'NATS_PASSWORD=' + password], - ) - context.docker.networks.get(context.prefix + 'nats').connect(context.prefix + 'nats-server') + pass @given(u'the NATS server is offline') def no_nats_server(context): - context.docker.containers.get(context.prefix + 'nats-server').stop() - context.docker.containers.get(context.prefix + 'nats-server').remove() - raise StepNotImplementedError() + nats_offline(context) # @@ -58,21 +37,16 @@ def no_nats_server(context): @given(u'I have the basic service configuration') def basic_service_configuration(context): - context.environment = { - 'NATS_URL': 'nats://localhost:4222', - 'NATS_USER': 'nats', - 'NATS_PASSWORD': 'nats', - 'SERVICE_NAME': 'my-service', - 'DATA_DIR': '/data', - } - - pass - # raise StepNotImplementedError() + environment_variable(context, 'NATS_URL', 'nats://localhost:4222') + environment_variable(context, 'NATS_USER', 'nats') + environment_variable(context, 'NATS_PASSWORD', 'nats') + environment_variable(context, 'SERVICE_NAME', 'my-service') + environment_variable(context, 'DATA_DIR', '/data') @given(u'I have no service configuration') def no_service_configuration(context): - raise StepNotImplementedError() + context.environment = {} @given(u'I have an environment variable named "{key}" with value "{value}"') From c49691d1dc94244922a7634701225c163ed3e124 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Thu, 12 Dec 2024 22:25:45 +0100 Subject: [PATCH 04/21] Removing unnecessary imports --- backend-features/docker_lib.py | 2 -- backend-features/steps/steps.py | 1 - 2 files changed, 3 deletions(-) diff --git a/backend-features/docker_lib.py b/backend-features/docker_lib.py index 72023656..32ef428e 100644 --- a/backend-features/docker_lib.py +++ b/backend-features/docker_lib.py @@ -1,5 +1,3 @@ -from os.path import exists - import docker diff --git a/backend-features/steps/steps.py b/backend-features/steps/steps.py index 34683a31..b8cf6f96 100644 --- a/backend-features/steps/steps.py +++ b/backend-features/steps/steps.py @@ -1,4 +1,3 @@ -import docker as dockerlib from behave import * from behave.api.pending_step import StepNotImplementedError From c52a8a18fa3a3847916187640f37f8f8de809eb2 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 13 Dec 2024 09:03:40 +0100 Subject: [PATCH 05/21] More and better implementations --- backend-features/docker_lib.py | 134 +++++++++++++++++++++++++++----- backend-features/environment.py | 24 +++--- backend-features/nats_config.py | 51 ++++++++++++ backend-features/steps/steps.py | 16 +++- 4 files changed, 194 insertions(+), 31 deletions(-) create mode 100644 backend-features/nats_config.py diff --git a/backend-features/docker_lib.py b/backend-features/docker_lib.py index 32ef428e..add10bbb 100644 --- a/backend-features/docker_lib.py +++ b/backend-features/docker_lib.py @@ -1,49 +1,147 @@ -import docker +import json +import os +from sys import flags +from docker.models.images import Image +from docker.client import DockerClient -def setup_nats(context): - # assert isinstance(context.docker, docker.client.DockerClient) - # assert exists(context.prefix) - context.docker.networks.create(nats_network_name(context)) +def setup_nats(context): + """ + Initialize the NATS server container and network + :param context: The context, including the Docker client. + """ + assert isinstance(context.docker, DockerClient) + assert hasattr(context, 'nats_config_file') + + nats_network = context.docker.networks.create(nats_network_name(context)) context.docker.containers.run( 'nats:latest', + '-c /nats/nats.conf', detach=True, name=nats_container_name(context), + network=nats_network.name, + hostname='nats', + volumes=[ + f"{context.nats_config_file}:/nats/nats.conf:ro" + ], ) - nats_network = context.docker.networks.get(nats_network_name(context)) - nats_network.connect(nats_container_name(context)) + + +def restart_nats(context): + """ + Restart the NATS server container + :param context: The context, including the Docker client. + """ + assert isinstance(context.docker, DockerClient) + + context.docker.containers.get(nats_container_name(context)).restart() def nats_container_name(context): + """ + Get the name of the NATS container + :param context: The context, including the prefix for Docker Object Names. + :return: The name of the NATS container + """ + assert hasattr(context, 'prefix') + assert isinstance(context.prefix, str) return 'g-' + context.prefix + 'nats-server' def nats_network_name(context): + """ + Get the name of the NATS network + :param context: The context, including the prefix for Docker Object Names + :return: The name of the NATS network + """ + assert hasattr(context, 'prefix') + assert isinstance(context.prefix, str) return 'g-' + context.prefix + 'nats' def teardown_nats(context): - # assert isinstance(context.docker, docker.client.DockerClient) - # assert exists(context.prefix) + assert isinstance(context.docker, DockerClient) context.docker.containers.get(nats_container_name(context)).stop() + print(context.docker.containers.get(nats_container_name(context)).logs().decode()) context.docker.containers.get(nats_container_name(context)).remove() - nats_network = context.docker.networks.get(nats_network_name(context)) - nats_network.remove() + nats_network(context).remove() def nats_online(context): - assert isinstance(context.docker, docker.DockerClient) - - network = context.docker.networks.get(nats_network_name(context)) - if nats_container_name(context) in [container.name for container in context.docker.containers.list()]: - return + """ + Connect the NATS container to the network + :param context: The context, including the Docker client. + """ + network = nats_network(context) + + if nats_container_name(context) in [ + container.name + for container + in network.containers + ]: return # already connected network.connect(nats_container_name(context)) def nats_offline(context): - assert isinstance(context.docker, docker.DockerClient) + """ + Disconnect the NATS container from the network + :param context: The context, including the Docker client. + """ + assert isinstance(context.docker, DockerClient) + nats_network(context).disconnect(nats_container_name(context)) + + +def nats_network(context): + """ + Get the NATS network + :param context: The context, including the Docker client. + :return: The NATS network object + """ + assert isinstance(context.docker, DockerClient) + return context.docker.networks.get(nats_network_name(context)) + + +def setup_testbed(context): + """ + Build the testbed image + :param context: The context, including the Docker client. + The testbed image will be stored in the context. + """ + assert isinstance(context.docker, DockerClient) + + [image, _output] = context.docker.images.build(path='.') + context.testbed = image + + +def run_testbed(context, cmd="", env=None) -> dict: + """ + Run a command in the testbed container + :param context: the context, including the Docker client + :param cmd: the command to run. + Default is an empty string + :param env: the environment variables to set. + :return: The JSON output of the command + """ + if env is None: + env = {} + assert isinstance(context.docker, DockerClient) + assert isinstance(context.testbed, Image) + assert isinstance(cmd, str) + assert isinstance(env, dict) + + result = context.docker.containers.run( + context.testbed, + cmd, + environment=env, + auto_remove=True, + network=nats_network_name(context), + ) + + json_result = json.loads(result) + + assert isinstance(json_result, dict) - context.docker.networks.get(nats_network_name(context)).disconnect(nats_container_name(context)) + return json_result diff --git a/backend-features/environment.py b/backend-features/environment.py index ac144b02..9d9f211c 100644 --- a/backend-features/environment.py +++ b/backend-features/environment.py @@ -1,25 +1,31 @@ import uuid import docker - -from docker_lib import setup_nats, teardown_nats +from docker_lib import * +from nats_config import reset_nats_config, setup_nats_config, teardown_nats_config def before_all(context): - # exit(1) + # Setup context variables context.docker = docker.from_env() context.prefix = generate_prefix() + context.environment = {} + # Setup the environment + setup_nats_config(context) setup_nats(context) + setup_testbed(context) def after_all(context): + # Teardown the environment teardown_nats(context) + teardown_nats_config(context) - -def after_scenario(context, scenario): - for container in context.docker.containers.list(all=True): - if container.name.startswith(context.prefix): - container.remove(force=True) - +def before_scenario(context, scenario): + reset_nats_config(context) def generate_prefix(): + """ + Generate a unique prefix for the test run to avoid name collisions in docker objects + :return: a unique prefix in the format 'telestion-backend-features--' + """ return 'telestion-backend-features-' + str(uuid.uuid4()) + '-' diff --git a/backend-features/nats_config.py b/backend-features/nats_config.py new file mode 100644 index 00000000..245ae8a9 --- /dev/null +++ b/backend-features/nats_config.py @@ -0,0 +1,51 @@ +import json +import tempfile +from os import unlink + + +def setup_nats_config(context): + [_fd, nats_config_file] = tempfile.mkstemp("backend-features-nats.conf", text=True) + context.nats_config_file = nats_config_file + reset_nats_config(context) + + +def teardown_nats_config(context): + assert hasattr(context, 'nats_config_file') + + print(f"Removing NATS config file: {context.nats_config_file}") + print(f"Contents of NATS config file: {open(context.nats_config_file).read()}") + + unlink(context.nats_config_file) + + +def write_nats_config(context): + assert hasattr(context, 'nats_config_file') + assert isinstance(context.nats_config_file, str) + assert hasattr(context, 'nats_config') + assert isinstance(context.nats_config, dict) + + with open(context.nats_config_file, 'w') as f: + # noinspection PyTypeChecker + json.dump(context.nats_config, f) + + +def reset_nats_config(context): + context.nats_config = {} + write_nats_config(context) + + +def update_nats_config(context, config): + assert isinstance(config, dict) + assert hasattr(context, 'nats_config') + assert isinstance(context.nats_config, dict) + + context.nats_config.update(config) + write_nats_config(context) + + +def listens_on(context, listens): + assert hasattr(context, 'nats_config') + assert isinstance(context.nats_config, dict) + + context.nats_config['listens'] = listens + write_nats_config(context) diff --git a/backend-features/steps/steps.py b/backend-features/steps/steps.py index b8cf6f96..7664ea0e 100644 --- a/backend-features/steps/steps.py +++ b/backend-features/steps/steps.py @@ -1,7 +1,8 @@ from behave import * from behave.api.pending_step import StepNotImplementedError -from docker_lib import nats_online, nats_offline +from docker_lib import nats_online, nats_offline, restart_nats +from nats_config import update_nats_config # @@ -11,17 +12,24 @@ @given(u'I have a NATS server running on "{host}"') def nats_server(context, host): + update_nats_config(context, {'listen': host}) + restart_nats(context) nats_online(context) @given(u'the NATS server requires authentication') def nats_server_auth(context): - pass + update_nats_config(context, {'authorization': { + 'user': 'nats', + 'password': 'SOME_UNKNOWN_SECRET_PASSWORD_UNTIL_PROPER_USER_IS_SET_UP' + }}) + restart_nats(context) @given(u'"{username}" is a NATS user with password "{password}"') def nats_server_user_credentials(context, username, password): - pass + update_nats_config(context, {'authorization': {'user': username, 'password': password}}) + restart_nats(context) @given(u'the NATS server is offline') @@ -50,7 +58,7 @@ def no_service_configuration(context): @given(u'I have an environment variable named "{key}" with value "{value}"') def environment_variable(context, key, value): - if context.environment is None: + if not hasattr(context, 'environment'): context.environment = {} context.environment[key] = value From 7e9326c9a740c3e9e4f02108e7b11f363d2ade23 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 13 Dec 2024 09:16:20 +0100 Subject: [PATCH 06/21] Basic test service execution --- backend-features/Dockerfile | 2 ++ backend-features/steps/steps.py | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend-features/Dockerfile b/backend-features/Dockerfile index 4add5a58..c07644df 100644 --- a/backend-features/Dockerfile +++ b/backend-features/Dockerfile @@ -6,3 +6,5 @@ RUN npm install -g gherkin-lint@3.3.1 # Set the working directory WORKDIR /features + +CMD ["echo", "{}"] \ No newline at end of file diff --git a/backend-features/steps/steps.py b/backend-features/steps/steps.py index 7664ea0e..78b29a36 100644 --- a/backend-features/steps/steps.py +++ b/backend-features/steps/steps.py @@ -1,7 +1,7 @@ from behave import * from behave.api.pending_step import StepNotImplementedError -from docker_lib import nats_online, nats_offline, restart_nats +from docker_lib import nats_online, nats_offline, restart_nats, run_testbed from nats_config import update_nats_config @@ -65,22 +65,22 @@ def environment_variable(context, key, value): @when(u'I start the service') def start_service(context): - raise StepNotImplementedError() + run_testbed(context, env=context.environment) @when(u'I start the service without NATS') def start_service_without_nats(context): - raise StepNotImplementedError() + run_testbed(context, env=context.environment.update({'X_DISABLE_NATS': '1'})) @when(u'I start the service with "{arg}"') def start_service_with_arg(context, arg): - raise StepNotImplementedError() + run_testbed(context, env=context.environment, cmd=arg) @when(u'I start the service with "{arg}" without NATS') def start_service_with_arg_without_nats(context, arg): - raise StepNotImplementedError() + run_testbed(context, env=context.environment.update({'X_DISABLE_NATS': '1'}), cmd=arg) @then(u'the service should start') From f57e0253303e494bd0a0025f8226973043a9f9ce Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 13 Dec 2024 12:03:29 +0100 Subject: [PATCH 07/21] Fully implement all steps logic --- backend-features/auth.feature | 19 +- backend-features/config.feature | 1 + backend-features/dev-mode.feature | 8 +- backend-features/docker_lib.py | 62 ++-- backend-features/environment.py | 2 + backend-features/nats.feature | 4 +- backend-features/nats_config.py | 8 - backend-features/sample-service/Dockerfile | 8 + .../sample-service/deno-testbed.ts | 72 +++++ backend-features/sample-service/deno.json | 13 + backend-features/sample-service/mod.ts | 285 ++++++++++++++++++ backend-features/service.feature | 2 +- backend-features/steps/config.py | 22 ++ backend-features/steps/nats.py | 31 ++ backend-features/steps/service.py | 79 +++++ backend-features/steps/steps.py | 113 ------- 16 files changed, 568 insertions(+), 161 deletions(-) create mode 100644 backend-features/sample-service/Dockerfile create mode 100644 backend-features/sample-service/deno-testbed.ts create mode 100644 backend-features/sample-service/deno.json create mode 100644 backend-features/sample-service/mod.ts create mode 100644 backend-features/steps/config.py create mode 100644 backend-features/steps/nats.py create mode 100644 backend-features/steps/service.py delete mode 100644 backend-features/steps/steps.py diff --git a/backend-features/auth.feature b/backend-features/auth.feature index 54dbb203..0d5c2c9a 100644 --- a/backend-features/auth.feature +++ b/backend-features/auth.feature @@ -2,10 +2,10 @@ Feature: NATS authentication Scenario: Starting the service with valid credentials Given I have the basic service configuration - And I have a NATS server running on "localhost:4222" + And I have a NATS server running on "nats:4222" And the NATS server requires authentication And "nats" is a NATS user with password "password" - And I have an environment variable named "NATS_URL" with value "localhost:4222" + And I have an environment variable named "NATS_URL" with value "nats:4222" And I have an environment variable named "NATS_USER" with value "nats" And I have an environment variable named "NATS_PASSWORD" with value "password" When I start the service @@ -14,29 +14,32 @@ Feature: NATS authentication Scenario: Starting the service with invalid credentials fails Given I have the basic service configuration - And I have a NATS server running on "localhost:4222" + And I have a NATS server running on "nats:4222" And the NATS server requires authentication And "nats" is a NATS user with password "password" - And I have an environment variable named "NATS_URL" with value "localhost:4222" + And I have an environment variable named "NATS_URL" with value "nats:4222" And I have an environment variable named "NATS_USER" with value "nats" And I have an environment variable named "NATS_PASSWORD" with value "wrong" + When I start the service Then the service should fail to start Scenario: Starting the service without credentials fails when the authentication is required Given I have the basic service configuration - And I have a NATS server running on "localhost:4222" + And I have a NATS server running on "nats:4222" And the NATS server requires authentication And "nats" is a NATS user with password "password" - And I have an environment variable named "NATS_URL" with value "localhost:4222" + And I have an environment variable named "NATS_URL" with value "nats:4222" + When I start the service Then the service should fail to start Scenario: Starting the service fails when the NATS server is offline Given I have the basic service configuration - And I have a NATS server running on "localhost:4222" + And I have a NATS server running on "nats:4222" And the NATS server requires authentication And "nats" is a NATS user with password "password" - And I have an environment variable named "NATS_URL" with value "localhost:4222" + And I have an environment variable named "NATS_URL" with value "nats:4222" And I have an environment variable named "NATS_USER" with value "nats" And I have an environment variable named "NATS_PASSWORD" with value "password" And the NATS server is offline + When I start the service Then the service should fail to start diff --git a/backend-features/config.feature b/backend-features/config.feature index f55cda28..5391f679 100644 --- a/backend-features/config.feature +++ b/backend-features/config.feature @@ -27,6 +27,7 @@ Feature: Service Configuration During development, it is possible to use the development mode so you don't have to provide these values. However, this is not recommended for production. Given I have no service configuration + When I start the service Then the service should fail to start Scenario: CLI arguments overwrite environment variables diff --git a/backend-features/dev-mode.feature b/backend-features/dev-mode.feature index 24739b33..a1579189 100644 --- a/backend-features/dev-mode.feature +++ b/backend-features/dev-mode.feature @@ -4,18 +4,18 @@ Feature: Development mode During development, it is useful to start the service with default parameters, so that it can be used without any configuration. Given I have no service configuration - And I have a NATS server running on "localhost:4222" When I start the service with "--dev" without NATS Then the service should start + And the service should be configured with "NATS_URL" set to "localhost:4222" And the service should be configured with "NATS_USER" set to "undefined" And the service should be configured with "NATS_PASSWORD" set to "undefined" Scenario: Any custom configuration overwrites dev mode parameters Given I have no service configuration - And I have a NATS server running on "localhost:4255" - And I have an environment variable named "NATS_URL" with value "localhost:4255" + And I have a NATS server running on "nats:4255" + And I have an environment variable named "NATS_URL" with value "nats:4255" When I start the service with "--dev --DATA_DIR=/tmp" Then the service should start And the service should connect to NATS And the service should be configured with "DATA_DIR" set to "/tmp" - And the service should be configured with "NATS_URL" set to "localhost:4255" + And the service should be configured with "NATS_URL" set to "nats:4255" diff --git a/backend-features/docker_lib.py b/backend-features/docker_lib.py index add10bbb..ba0dffcc 100644 --- a/backend-features/docker_lib.py +++ b/backend-features/docker_lib.py @@ -1,9 +1,7 @@ import json -import os -from sys import flags -from docker.models.images import Image from docker.client import DockerClient +from docker.models.images import Image def setup_nats(context): @@ -64,7 +62,8 @@ def teardown_nats(context): assert isinstance(context.docker, DockerClient) context.docker.containers.get(nats_container_name(context)).stop() - print(context.docker.containers.get(nats_container_name(context)).logs().decode()) + # Output NATS logs – useful for debugging: + # print(context.docker.containers.get(nats_container_name(context)).logs().decode()) context.docker.containers.get(nats_container_name(context)).remove() nats_network(context).remove() @@ -82,7 +81,8 @@ def nats_online(context): in network.containers ]: return # already connected - network.connect(nats_container_name(context)) + network.connect(nats_container_name(context), aliases=['nats']) + restart_nats(context) def nats_offline(context): @@ -112,36 +112,48 @@ def setup_testbed(context): """ assert isinstance(context.docker, DockerClient) - [image, _output] = context.docker.images.build(path='.') + [image, _output] = context.docker.images.build(path='./sample-service') context.testbed = image -def run_testbed(context, cmd="", env=None) -> dict: +def run_testbed(context, cmd="", disable_nats=False) -> dict: """ Run a command in the testbed container :param context: the context, including the Docker client :param cmd: the command to run. Default is an empty string - :param env: the environment variables to set. + :param disable_nats: Starts the service in the no-NATS mode :return: The JSON output of the command """ - if env is None: - env = {} - assert isinstance(context.docker, DockerClient) - assert isinstance(context.testbed, Image) - assert isinstance(cmd, str) - assert isinstance(env, dict) - - result = context.docker.containers.run( - context.testbed, - cmd, - environment=env, - auto_remove=True, - network=nats_network_name(context), - ) + assert hasattr(context, 'docker'), 'Docker client is required for this step' + assert isinstance(context.docker, DockerClient), 'Docker client must be an instance of DockerClient' + assert hasattr(context, 'testbed'), 'Testbed image is required for this step' + assert isinstance(context.testbed, Image), 'Testbed image must be an instance of Image' + assert hasattr(context, 'environment'), 'Environment variables are required for this step' + assert isinstance(context.environment, dict), 'Environment variables must be a dictionary' + assert isinstance(cmd, str), 'Command must be a string' + + env = context.environment.copy() + env['X_DISABLE_NATS'] = '1' if disable_nats else '0' + + try: + result = context.docker.containers.run( + context.testbed, + cmd, + environment=env, + auto_remove=True, + network=nats_network_name(context), + ).splitlines()[-1].decode() + + print(f"Result: {result}") + + json_result = json.loads(result) - json_result = json.loads(result) + assert isinstance(json_result, dict) - assert isinstance(json_result, dict) + context.result = json_result - return json_result + return json_result + except Exception as e: + print(f"Error: {e}") + return {} diff --git a/backend-features/environment.py b/backend-features/environment.py index 9d9f211c..220bd36a 100644 --- a/backend-features/environment.py +++ b/backend-features/environment.py @@ -20,9 +20,11 @@ def after_all(context): teardown_nats(context) teardown_nats_config(context) + def before_scenario(context, scenario): reset_nats_config(context) + def generate_prefix(): """ Generate a unique prefix for the test run to avoid name collisions in docker objects diff --git a/backend-features/nats.feature b/backend-features/nats.feature index bd049da9..d0163928 100644 --- a/backend-features/nats.feature +++ b/backend-features/nats.feature @@ -4,7 +4,7 @@ Feature: NATS Integration in Services The service should be able to access the NATS client after startup. This enables service developers to use the NATS client to publish and subscribe to messages. Given I have the basic service configuration - And I have a NATS server running on "localhost:4222" + And I have a NATS server running on "nats:4222" When I start the service Then the service should connect to NATS And the NATS connection API should be available to the service @@ -13,6 +13,6 @@ Feature: NATS Integration in Services The developer may want to disable the NATS integration for testing purposes or because the service does not need NATS. Given I have the basic service configuration - And I have a NATS server running on "localhost:4222" + And I have a NATS server running on "nats:4222" When I start the service without NATS Then the service should not connect to NATS diff --git a/backend-features/nats_config.py b/backend-features/nats_config.py index 245ae8a9..c32cd1f1 100644 --- a/backend-features/nats_config.py +++ b/backend-features/nats_config.py @@ -41,11 +41,3 @@ def update_nats_config(context, config): context.nats_config.update(config) write_nats_config(context) - - -def listens_on(context, listens): - assert hasattr(context, 'nats_config') - assert isinstance(context.nats_config, dict) - - context.nats_config['listens'] = listens - write_nats_config(context) diff --git a/backend-features/sample-service/Dockerfile b/backend-features/sample-service/Dockerfile new file mode 100644 index 00000000..0608f7c5 --- /dev/null +++ b/backend-features/sample-service/Dockerfile @@ -0,0 +1,8 @@ +FROM denoland/deno:alpine-2.1.4 + +WORKDIR /app + +COPY . . +RUN deno install + +ENTRYPOINT [ "deno", "run", "-A", "deno-testbed.ts" ] \ No newline at end of file diff --git a/backend-features/sample-service/deno-testbed.ts b/backend-features/sample-service/deno-testbed.ts new file mode 100644 index 00000000..9eef18a5 --- /dev/null +++ b/backend-features/sample-service/deno-testbed.ts @@ -0,0 +1,72 @@ +import { startService } from "jsr:@wuespace/telestion"; + +const disableNats = Deno.env.get("X_DISABLE_NATS") === "1"; + +const result: { + /** + * Whether the service has been started successfully. + */ + started: boolean; + /** + * Whether the NATS API is available. + */ + nats_api_available: boolean; + /** + * Whether the service is connected to the NATS server. + */ + nats_connected: boolean; + /** + * The configuration of the service. + */ + config: Record; + /** + * Details about an error that occurred during startup. + */ + error?: string; + /** + * The environment variables of the service. + */ + env: Record; +} = { + started: false, + nats_api_available: false, + nats_connected: false, + config: {}, + env: Deno.env.toObject(), +}; + +try { + if (disableNats) { + const res = await startService({ nats: false }); + result.config = res.config; // We have a config + result.started = true; // We have started the service + try { + // This should throw an error as NATS is disabled + const nats = res.nc; + result.nats_api_available = true; + result.nats_connected = nats.isClosed() === false; + } catch { /**/ } + } else { + const res = await startService(); + result.started = true; // We have started the service + result.config = res.config; // We have a config + + try { + const nats = res.nc; // This should not throw an error – NATS is enabled + result.nats_api_available = true; + result.nats_connected = nats.isClosed() === false; + } catch { /**/ } + } +} catch (e) { + // An error occurred during startup. result.started is still false. + // Let's add some more details about the error in case it wasn't expected. + if (e instanceof Error) { + result.error = e.message; + } else { + result.error = "Unknown error"; + } +} finally { + // No matter what happens, the last printed line must be the JSON result string and the script must exit with code 0. + console.log(JSON.stringify(result)); + Deno.exit(); // Important – otherwise the script will keep running +} diff --git a/backend-features/sample-service/deno.json b/backend-features/sample-service/deno.json new file mode 100644 index 00000000..38fbeb55 --- /dev/null +++ b/backend-features/sample-service/deno.json @@ -0,0 +1,13 @@ +{ + "lock": false, + "imports": { + "@nats-io/nats-core": "jsr:@nats-io/nats-core@3.0.0-45", + "@nats-io/transport-deno": "jsr:@nats-io/transport-deno@3.0.0-18", + "@std/assert": "jsr:@std/assert@^1.0.8", + "@std/cli": "jsr:@std/cli@^1.0.7", + "@std/jsonc": "jsr:@std/jsonc@^1.0.1", + "@std/path": "jsr:@std/path@^1.0.8", + "jsr:@wuespace/telestion": "./mod.ts", + "zod": "npm:zod@^3.23.8" + } +} diff --git a/backend-features/sample-service/mod.ts b/backend-features/sample-service/mod.ts new file mode 100644 index 00000000..55641acf --- /dev/null +++ b/backend-features/sample-service/mod.ts @@ -0,0 +1,285 @@ +import type { NatsConnection } from "@nats-io/nats-core"; +import * as natsTransport from "@nats-io/transport-deno"; +import { parseArgs } from "@std/cli"; +import { parse as parseJSON } from "@std/jsonc"; +import { resolve } from "@std/path"; +import { z, type ZodSchema, type ZodTypeDef } from "zod"; + +let args = Deno.args; +let natsModule = natsTransport; + +/** + * Options passed to the {@link startService} function. + * + * ### Properties + * - {@link StartServiceConfig.nats} + * - {@link StartServiceConfig.overwriteArgs} + * - {@link StartServiceConfig.natsMock} + * + * @see {@link startService} + */ +export interface StartServiceConfig { + /** + * Whether to enable NATS or not. Disabling NATS can be useful during development. + * @default true + */ + nats: boolean; + /** + * An array of arguments that should overwrite the CLI arguments. Useful for testing. + */ + overwriteArgs?: string[]; + /** + * A mock for the NATS module. Useful for testing. + */ + natsMock?: unknown; +} + +const StartServiceConfigSchema: ZodSchema< + StartServiceConfig, + ZodTypeDef, + Partial +> = z.object({ + nats: z.boolean().default(true), + overwriteArgs: z.array(z.string()).optional(), + natsMock: z.unknown().optional(), +}); + +/** + * The minimal configuration for a Telestion service. Returned as `config` property by {@link startService}. + * + * ### Properties + * - {@link MinimalConfig.NATS_URL} + * - {@link MinimalConfig.NATS_USER} + * - {@link MinimalConfig.NATS_PASSWORD} + * - {@link MinimalConfig.SERVICE_NAME} + * - {@link MinimalConfig.DATA_DIR} + * + * Can also contain additional properties under `MinimalConfig[key: string]: unknown`. + */ +export interface MinimalConfig { + /** + * The URL of the NATS server. + */ + NATS_URL: string; + /** + * The username for the NATS server. + */ + NATS_USER?: string; + /** + * The password for the NATS server. + */ + NATS_PASSWORD?: string; + /** + * The name of the service. + */ + SERVICE_NAME: string; + /** + * The path to the data directory. + */ + DATA_DIR: string; + /** + * Additional properties. + */ + [key: string]: unknown; +} + +/** + * The minimal configuration for a Telestion service. Gets used internally by {@link startService}. + * + * See {@link MinimalConfig} for details about the resulting configuration object. + * + * @example Manually parsing the configuration + * ```ts + * import {MinimalConfigSchema} from "https://deno.land/x/telestion/mod.ts"; + * + * const rawConfig: unknown = Deno.env.toObject(); + * + * const config = MinimalConfigSchema.parse(rawConfig); + * console.log(config.SERVICE_NAME); // "my-service" + * ``` + */ +export const MinimalConfigSchema: ZodSchema< + MinimalConfig, + ZodTypeDef, + MinimalConfig +> = z.object({ + NATS_URL: z.string(), + NATS_USER: z.string().optional(), + NATS_PASSWORD: z.string().optional(), + SERVICE_NAME: z.string(), + DATA_DIR: z.string(), +}).passthrough(); + +/** + * Starts the service and returns the APIs available to the Telestion service. + + * ### Service APIs returned by this function + * - `nc: NATSConnection` The NATS connection object. + * - `messageBus: MessageBus` The NATS message bus. Alias for `nc`. + * - `dataDir: string` The path to the data directory. + * - `serviceName: string` The name of the service. + * - `config: MinimalConfig` The configuration of the service. + + * @param rawOptions The configuration for the service. See {@link StartServiceConfig} for details. + * + * @throws If the service couldn't be started. + * + * @example + * ```ts + * import {startService, JSONCodec} from "./lib.ts"; + * + * const {nc, serviceName} = await startService(); + * console.log(`Service ${serviceName} started!`); + * + * nc.publish("my-topic", JSONCodec().encode({foo: "bar"})); + * console.log("Message published!"); + * + * await nc.drain(); + * ``` + */ +export async function startService( + rawOptions: z.input = + StartServiceConfigSchema.parse({}), +): Promise<{ + nc: NatsConnection; + messageBus: NatsConnection; + dataDir: string; + serviceName: string; + config: MinimalConfig; +}> { + const options = StartServiceConfigSchema.parse(rawOptions); + args = options.overwriteArgs ?? Deno.args; + natsModule = options.natsMock as typeof natsTransport ?? natsTransport; + + const config = assembleConfig(); + + return { + // Non-NATS APIs + get nc(): never { + throw new Error("NATS is not enabled"); + }, + get messageBus(): never { + throw new Error("NATS is not enabled"); + }, + // NATS APIs + ...(options.nats ? await initializeNats(config) : {}), + // Other APIs + dataDir: resolve(config.DATA_DIR), + serviceName: config.SERVICE_NAME, + config, + }; +} + +/** + * Assembles the configuration for the service. + */ +function assembleConfig() { + const flags = parseArgs(args); + + /** + * Configuration parameters that are passed via environment variables or command line arguments. + */ + const withoutConfigFile = z.object({ + CONFIG_FILE: z.string().optional(), + CONFIG_KEY: z.string().optional(), + }).passthrough().parse({ + ...getDefaultConfig(), + ...Deno.env.toObject(), + ...flags, + }); + + // No config file => return the parsed config + if (!withoutConfigFile.CONFIG_FILE) { + return ensureMinimalConfig(withoutConfigFile); + } + + const config = parseJSON( + Deno.readTextFileSync(withoutConfigFile.CONFIG_FILE), + ); + + // Config file doesn't contain an object => throw error + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw new Error("Invalid config file"); + } + + // No config key => return the parsed config combined with the root config file object + if (!withoutConfigFile.CONFIG_KEY) { + return ensureMinimalConfig({ ...config, ...withoutConfigFile }); + } + + const childConfig = config[withoutConfigFile.CONFIG_KEY]; + + // Config key doesn't exist or doesn't lead to an object => throw error + if ( + typeof childConfig !== "object" || childConfig === null || + Array.isArray(childConfig) + ) { + throw new Error("Invalid config file"); + } + + // Return the parsed config combined with the child config object + return ensureMinimalConfig({ ...childConfig, ...withoutConfigFile }); +} + +function ensureMinimalConfig(rawConfig: unknown) { + try { + return MinimalConfigSchema.parse(rawConfig); + } catch (e) { + const flags = parseArgs(args); + console.error("Missing required configuration parameters."); + console.info(`Details: ${e}`); + + if (!flags["dev"]) { + console.info( + 'Run with "--dev" to use default values for missing environment variables during development.', + ); + } + throw e; + } +} + +function getDefaultConfig() { + const flags = parseArgs(args); + if (!flags["dev"]) { + return {}; + } + + console.log( + "Running in development mode. Using default values for missing environment variables.", + ); + return { + NATS_URL: "localhost:4222", + SERVICE_NAME: `dev-${Deno.gid}`, + DATA_DIR: resolve("./data"), + }; +} + +async function initializeNats(config: MinimalConfig) { + try { + const nc = await natsModule.connect({ + servers: config.NATS_URL, + user: config.NATS_USER, + pass: config.NATS_PASSWORD, + }); + + // Register health check + nc.subscribe("__telestion__.health", { + callback: (err, msg) => { + if (err) { + return; + } + msg.respond( + JSON.stringify({ + name: config.SERVICE_NAME, + }), + ); + }, + }); + return { nc, messageBus: nc }; + } catch (e) { + console.error( + `Error! Couldn't connect to the message bus. Details: ${e}`, + ); + throw e; + } +} diff --git a/backend-features/service.feature b/backend-features/service.feature index e1afed52..768fa5bb 100644 --- a/backend-features/service.feature +++ b/backend-features/service.feature @@ -4,6 +4,6 @@ Feature: Service Lifecycle The most trivial scenario of them all. We start the service and it should start. That's it. No more, no less. But it's a good start. Given I have the basic service configuration - And I have a NATS server running on "localhost:4222" + And I have a NATS server running on "nats:4222" When I start the service Then the service should start diff --git a/backend-features/steps/config.py b/backend-features/steps/config.py new file mode 100644 index 00000000..a3e3ae64 --- /dev/null +++ b/backend-features/steps/config.py @@ -0,0 +1,22 @@ +from behave import given + + +@given(u'I have the basic service configuration') +def basic_service_configuration(context): + environment_variable(context, 'NATS_URL', 'nats://nats:4222') + environment_variable(context, 'NATS_USER', 'nats') + environment_variable(context, 'NATS_PASSWORD', 'nats') + environment_variable(context, 'SERVICE_NAME', 'my-service') + environment_variable(context, 'DATA_DIR', '/data') + + +@given(u'I have no service configuration') +def no_service_configuration(context): + context.environment = {} + + +@given(u'I have an environment variable named "{key}" with value "{value}"') +def environment_variable(context, key, value): + if not hasattr(context, 'environment'): + context.environment = {} + context.environment[key] = value diff --git a/backend-features/steps/nats.py b/backend-features/steps/nats.py new file mode 100644 index 00000000..d44f82cf --- /dev/null +++ b/backend-features/steps/nats.py @@ -0,0 +1,31 @@ +from behave import given + +from docker_lib import restart_nats, nats_online, nats_offline +from nats_config import update_nats_config + + +@given(u'I have a NATS server running on "{host}"') +def nats_server(context, host): + update_nats_config(context, {'listen': host}) + restart_nats(context) + nats_online(context) + + +@given(u'the NATS server requires authentication') +def nats_server_auth(context): + update_nats_config(context, {'authorization': { + 'user': 'nats', + 'password': 'SOME_UNKNOWN_SECRET_PASSWORD_UNTIL_PROPER_USER_IS_SET_UP' + }}) + restart_nats(context) + + +@given(u'"{username}" is a NATS user with password "{password}"') +def nats_server_user_credentials(context, username, password): + update_nats_config(context, {'authorization': {'user': username, 'password': password}}) + restart_nats(context) + + +@given(u'the NATS server is offline') +def no_nats_server(context): + nats_offline(context) diff --git a/backend-features/steps/service.py b/backend-features/steps/service.py new file mode 100644 index 00000000..faebd7d5 --- /dev/null +++ b/backend-features/steps/service.py @@ -0,0 +1,79 @@ +from behave import when, then +from behave.api.pending_step import StepNotImplementedError + +from docker_lib import run_testbed + + +def service_env(context, disable_nats=False): + assert hasattr(context, 'nats_config'), 'NATS configuration is required for this step' + assert isinstance(context.nats_config, dict), 'NATS configuration must be a dictionary' + + env = context.nats_config.copy() + env['X_DISABLE_NATS'] = '1' if disable_nats else '0' + return env + + +@when(u'I start the service') +def start_service(context): + run_testbed(context) + + +@when(u'I start the service without NATS') +def start_service_without_nats(context): + run_testbed(context, disable_nats=True) + + +@when(u'I start the service with "{arg}"') +def start_service_with_arg(context, arg): + run_testbed(context, cmd=arg) + + +@when(u'I start the service with "{arg}" without NATS') +def start_service_with_arg_without_nats(context, arg): + run_testbed(context, cmd=arg, disable_nats=True) + + +@then(u'the service should start') +def assert_service_started(context): + assert 'result' in context, 'Service must be started before checking connection' + assert 'started' in context.result, 'Service must be started before checking connection' + assert context.result['started'], 'Service must be started' + + +@then(u'the service should connect to NATS') +def assert_service_connected(context): + assert 'result' in context, 'Service must be started before checking connection' + assert 'nats_connected' in context.result, 'Service must be started before checking connection' + assert context.result['nats_connected'], 'Service must be connected to NATS' + # raise StepNotImplementedError() + + +@then(u'the service should fail to start') +def assert_service_failed(context): + assert 'result' in context, 'Result must be available to check service status' + assert 'started' in context.result, 'Service must be started before checking connection' + assert not context.result['started'], 'Service must not be started' + + +@then(u'the service should be configured with "{key}" set to "{expected_value}"') +def assert_service_configured_with(context, key, expected_value): + assert 'result' in context, 'Service must be started before checking connection' + assert 'config' in context.result, 'Service must be started before checking connection' + if expected_value == "undefined": + assert key not in context.result['config'], 'Service must be started before checking connection' + else: + assert key in context.result['config'], 'Service must be started before checking connection' + assert str(context.result['config'][key]) == str( + expected_value), f"Expected {key} to be {expected_value}, got {context.result['config'][key]}" + + +@then(u'the NATS connection API should be available to the service') +def assert_nats_api_available(context): + assert 'result' in context, 'Service must be started before checking connection' + assert 'nats_api_available' in context.result, 'Service must be started before checking connection' + assert context.result['nats_api_available'], 'Service must be connected to NATS' + + +@then(u'the service should not connect to NATS') +def assert_service_not_connected(context): + raise StepNotImplementedError() diff --git a/backend-features/steps/steps.py b/backend-features/steps/steps.py deleted file mode 100644 index 78b29a36..00000000 --- a/backend-features/steps/steps.py +++ /dev/null @@ -1,113 +0,0 @@ -from behave import * -from behave.api.pending_step import StepNotImplementedError - -from docker_lib import nats_online, nats_offline, restart_nats, run_testbed -from nats_config import update_nats_config - - -# -# nats server setup -# - - -@given(u'I have a NATS server running on "{host}"') -def nats_server(context, host): - update_nats_config(context, {'listen': host}) - restart_nats(context) - nats_online(context) - - -@given(u'the NATS server requires authentication') -def nats_server_auth(context): - update_nats_config(context, {'authorization': { - 'user': 'nats', - 'password': 'SOME_UNKNOWN_SECRET_PASSWORD_UNTIL_PROPER_USER_IS_SET_UP' - }}) - restart_nats(context) - - -@given(u'"{username}" is a NATS user with password "{password}"') -def nats_server_user_credentials(context, username, password): - update_nats_config(context, {'authorization': {'user': username, 'password': password}}) - restart_nats(context) - - -@given(u'the NATS server is offline') -def no_nats_server(context): - nats_offline(context) - - -# -# service setup -# - - -@given(u'I have the basic service configuration') -def basic_service_configuration(context): - environment_variable(context, 'NATS_URL', 'nats://localhost:4222') - environment_variable(context, 'NATS_USER', 'nats') - environment_variable(context, 'NATS_PASSWORD', 'nats') - environment_variable(context, 'SERVICE_NAME', 'my-service') - environment_variable(context, 'DATA_DIR', '/data') - - -@given(u'I have no service configuration') -def no_service_configuration(context): - context.environment = {} - - -@given(u'I have an environment variable named "{key}" with value "{value}"') -def environment_variable(context, key, value): - if not hasattr(context, 'environment'): - context.environment = {} - context.environment[key] = value - - -@when(u'I start the service') -def start_service(context): - run_testbed(context, env=context.environment) - - -@when(u'I start the service without NATS') -def start_service_without_nats(context): - run_testbed(context, env=context.environment.update({'X_DISABLE_NATS': '1'})) - - -@when(u'I start the service with "{arg}"') -def start_service_with_arg(context, arg): - run_testbed(context, env=context.environment, cmd=arg) - - -@when(u'I start the service with "{arg}" without NATS') -def start_service_with_arg_without_nats(context, arg): - run_testbed(context, env=context.environment.update({'X_DISABLE_NATS': '1'}), cmd=arg) - - -@then(u'the service should start') -def assert_service_started(context): - raise StepNotImplementedError() - - -@then(u'the service should connect to NATS') -def assert_service_connected(context): - raise StepNotImplementedError() - - -@then(u'the service should fail to start') -def assert_service_failed(context): - raise StepNotImplementedError() - - -@then(u'the service should be configured with "{key}" set to "{expected_value}"') -def assert_service_configured_with(context, key, expected_value): - raise StepNotImplementedError() - - -@then(u'the NATS connection API should be available to the service') -def assert_nats_api_available(context): - raise StepNotImplementedError() - - -@then(u'the service should not connect to NATS') -def assert_service_not_connected(context): - raise StepNotImplementedError() From 8662a14d8b2e47209d5099986eed74271c788460 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 13 Dec 2024 16:16:13 +0100 Subject: [PATCH 08/21] Use `Background:` in features for better structuring --- backend-features/auth.feature | 24 +++++++----------------- backend-features/nats.feature | 4 ++-- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/backend-features/auth.feature b/backend-features/auth.feature index 0d5c2c9a..9b11cb64 100644 --- a/backend-features/auth.feature +++ b/backend-features/auth.feature @@ -1,11 +1,13 @@ Feature: NATS authentication - Scenario: Starting the service with valid credentials + Background: A NATS server with authentication is running on "nats:4222" Given I have the basic service configuration And I have a NATS server running on "nats:4222" And the NATS server requires authentication And "nats" is a NATS user with password "password" - And I have an environment variable named "NATS_URL" with value "nats:4222" + + Scenario: Starting the service with valid credentials + Given I have an environment variable named "NATS_URL" with value "nats:4222" And I have an environment variable named "NATS_USER" with value "nats" And I have an environment variable named "NATS_PASSWORD" with value "password" When I start the service @@ -13,31 +15,19 @@ Feature: NATS authentication And the service should connect to NATS Scenario: Starting the service with invalid credentials fails - Given I have the basic service configuration - And I have a NATS server running on "nats:4222" - And the NATS server requires authentication - And "nats" is a NATS user with password "password" - And I have an environment variable named "NATS_URL" with value "nats:4222" + Given I have an environment variable named "NATS_URL" with value "nats:4222" And I have an environment variable named "NATS_USER" with value "nats" And I have an environment variable named "NATS_PASSWORD" with value "wrong" When I start the service Then the service should fail to start Scenario: Starting the service without credentials fails when the authentication is required - Given I have the basic service configuration - And I have a NATS server running on "nats:4222" - And the NATS server requires authentication - And "nats" is a NATS user with password "password" - And I have an environment variable named "NATS_URL" with value "nats:4222" + Given I have an environment variable named "NATS_URL" with value "nats:4222" When I start the service Then the service should fail to start Scenario: Starting the service fails when the NATS server is offline - Given I have the basic service configuration - And I have a NATS server running on "nats:4222" - And the NATS server requires authentication - And "nats" is a NATS user with password "password" - And I have an environment variable named "NATS_URL" with value "nats:4222" + Given I have an environment variable named "NATS_URL" with value "nats:4222" And I have an environment variable named "NATS_USER" with value "nats" And I have an environment variable named "NATS_PASSWORD" with value "password" And the NATS server is offline diff --git a/backend-features/nats.feature b/backend-features/nats.feature index d0163928..3c39c35e 100644 --- a/backend-features/nats.feature +++ b/backend-features/nats.feature @@ -1,10 +1,11 @@ Feature: NATS Integration in Services + Background: A NATS server is running on "nats:4222" + Given I have a NATS server running on "nats:4222" Scenario: The service has access to the NATS client after startup The service should be able to access the NATS client after startup. This enables service developers to use the NATS client to publish and subscribe to messages. Given I have the basic service configuration - And I have a NATS server running on "nats:4222" When I start the service Then the service should connect to NATS And the NATS connection API should be available to the service @@ -13,6 +14,5 @@ Feature: NATS Integration in Services The developer may want to disable the NATS integration for testing purposes or because the service does not need NATS. Given I have the basic service configuration - And I have a NATS server running on "nats:4222" When I start the service without NATS Then the service should not connect to NATS From c2135d1b35782f76fedfaff64e114d3f1503bcd5 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 13 Dec 2024 16:17:35 +0100 Subject: [PATCH 09/21] Remove Gherkin Linter It doesn't really do a whole lot and just adds clutter at the moment. --- backend-features/.gherkin-lintrc | 25 ------------------------- backend-features/Dockerfile | 10 ---------- backend-features/docker-compose.yml | 8 -------- 3 files changed, 43 deletions(-) delete mode 100644 backend-features/.gherkin-lintrc delete mode 100644 backend-features/Dockerfile delete mode 100644 backend-features/docker-compose.yml diff --git a/backend-features/.gherkin-lintrc b/backend-features/.gherkin-lintrc deleted file mode 100644 index cbcd91e1..00000000 --- a/backend-features/.gherkin-lintrc +++ /dev/null @@ -1,25 +0,0 @@ -{ - "no-files-without-scenarios" : "on", - "no-unnamed-features": "on", - "no-unnamed-scenarios": "on", - "no-dupe-scenario-names": ["on", "in-feature"], - "no-dupe-feature-names": "on", - "no-partially-commented-tag-lines": "on", - "indentation": "off", - "no-trailing-spaces": "on", - "new-line-at-eof": ["on", "yes"], - "no-multiple-empty-lines": "on", - "no-empty-file": "on", - "no-scenario-outlines-without-examples": "on", - "name-length": ["on", {"Feature": 50, "Step": 90, "Scenario": 90}], - "no-restricted-tags": ["on", {"tags": ["@watch", "@wip"]}], - "use-and": "on", - "no-duplicate-tags": "on", - "no-superfluous-tags": "on", - "no-homogenous-tags": "on", - "one-space-between-tags": "on", - "no-unused-variables": "on", - "no-background-only-scenario": "on", - "no-empty-background": "on", - "scenario-size": ["on", { "steps-length": {"Background": 15, "Scenario": 15}}] -} \ No newline at end of file diff --git a/backend-features/Dockerfile b/backend-features/Dockerfile deleted file mode 100644 index c07644df..00000000 --- a/backend-features/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM node:21-alpine -LABEL authors="zklaschka" - -# Install gherkin-lint and feature-to-md -RUN npm install -g gherkin-lint@3.3.1 - -# Set the working directory -WORKDIR /features - -CMD ["echo", "{}"] \ No newline at end of file diff --git a/backend-features/docker-compose.yml b/backend-features/docker-compose.yml deleted file mode 100644 index 17e302bf..00000000 --- a/backend-features/docker-compose.yml +++ /dev/null @@ -1,8 +0,0 @@ -services: - lint: - build: - context: . - dockerfile: Dockerfile - volumes: - - .:/features - entrypoint: "gherkin-lint" From 670bfc4a122405ba80bb72f4c6c1550bb93707ab Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 13 Dec 2024 17:15:29 +0100 Subject: [PATCH 10/21] Implement "Then the service should not connect to NATS" step --- backend-features/steps/service.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend-features/steps/service.py b/backend-features/steps/service.py index faebd7d5..54f88cd1 100644 --- a/backend-features/steps/service.py +++ b/backend-features/steps/service.py @@ -76,4 +76,6 @@ def assert_nats_api_available(context): @then(u'the service should not connect to NATS') def assert_service_not_connected(context): - raise StepNotImplementedError() + assert 'result' in context, 'Service must be started before checking connection' + assert 'nats_connected' in context.result, 'Service must be started before checking connection' + assert not context.result['nats_connected'], 'Service must not be connected to NATS' From d3bef99e081226aea34d8ec515c98b74f4885e1f Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 13 Dec 2024 19:24:48 +0100 Subject: [PATCH 11/21] Many more improvements --- backend-features/README.md | 40 ++++++++- backend-features/environment.py | 5 +- backend-features/lib/__init__.py | 0 backend-features/{ => lib}/nats_config.py | 6 +- .../{docker_lib.py => lib/nats_container.py} | 67 ++------------- backend-features/lib/test-result.schema.json | 64 ++++++++++++++ backend-features/lib/testbed.py | 86 +++++++++++++++++++ backend-features/requirements.txt | 2 + backend-features/run-tests.py | 33 +++++++ .../sample-service/deno-testbed.ts | 3 +- backend-features/steps/nats.py | 4 +- backend-features/steps/service.py | 6 +- 12 files changed, 245 insertions(+), 71 deletions(-) create mode 100644 backend-features/lib/__init__.py rename backend-features/{ => lib}/nats_config.py (82%) rename backend-features/{docker_lib.py => lib/nats_container.py} (56%) create mode 100644 backend-features/lib/test-result.schema.json create mode 100644 backend-features/lib/testbed.py create mode 100755 backend-features/run-tests.py diff --git a/backend-features/README.md b/backend-features/README.md index 8e79465d..c72a3727 100644 --- a/backend-features/README.md +++ b/backend-features/README.md @@ -22,6 +22,38 @@ The Gherkin files get converted to Markdown files that are then included in the ## Testing +### Architecture + +```mermaid +graph TD + R[Test Runner] + subgraph Docker + T[Testbed Container] + N[NATS Container] + T --- W[Network] -- Host: nats --- N + end + NC[Temporary NATS Config File] + NC -- mounted into --- N + R -- writes --> NC + R -- runs --> T + T -- result --> R + R -- controls containers of --> W + R -- starts/restarts --> N +``` + +```mermaid +graph LR + I[Testbed Image] + R[Test Runner] + subgraph Docker + C[Testbed Container] + end + R -- builds --> I + R -- runs --> C + C -- result --> R + C -- is based on --> I +``` + ### Natively Create the Python virtual environment with the helper script: @@ -39,5 +71,11 @@ Source the virtual environment: Run the tests with: ```shell -behave +./run-tests.py ./sample-service ``` + +> [!NOTE] +> You can also run the tests from any other directory. + +> [!NOTE] +> Run `./run-tests.py --help` for more options, such as verbose output. diff --git a/backend-features/environment.py b/backend-features/environment.py index 220bd36a..c7f74460 100644 --- a/backend-features/environment.py +++ b/backend-features/environment.py @@ -1,7 +1,8 @@ import uuid import docker -from docker_lib import * -from nats_config import reset_nats_config, setup_nats_config, teardown_nats_config +from lib.nats_config import reset_nats_config, setup_nats_config, teardown_nats_config +from lib.nats_container import setup_nats, teardown_nats +from lib.testbed import setup_testbed def before_all(context): diff --git a/backend-features/lib/__init__.py b/backend-features/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend-features/nats_config.py b/backend-features/lib/nats_config.py similarity index 82% rename from backend-features/nats_config.py rename to backend-features/lib/nats_config.py index c32cd1f1..e6c5b24d 100644 --- a/backend-features/nats_config.py +++ b/backend-features/lib/nats_config.py @@ -1,4 +1,5 @@ import json +import os import tempfile from os import unlink @@ -12,8 +13,9 @@ def setup_nats_config(context): def teardown_nats_config(context): assert hasattr(context, 'nats_config_file') - print(f"Removing NATS config file: {context.nats_config_file}") - print(f"Contents of NATS config file: {open(context.nats_config_file).read()}") + if 'VERBOSE' in os.environ and os.environ['VERBOSE']: + print(f"Removing NATS config file: {context.nats_config_file}") + print(f"Contents of NATS config file: {open(context.nats_config_file).read()}") unlink(context.nats_config_file) diff --git a/backend-features/docker_lib.py b/backend-features/lib/nats_container.py similarity index 56% rename from backend-features/docker_lib.py rename to backend-features/lib/nats_container.py index ba0dffcc..06c9b8ac 100644 --- a/backend-features/docker_lib.py +++ b/backend-features/lib/nats_container.py @@ -1,7 +1,6 @@ -import json +import os from docker.client import DockerClient -from docker.models.images import Image def setup_nats(context): @@ -44,6 +43,7 @@ def nats_container_name(context): """ assert hasattr(context, 'prefix') assert isinstance(context.prefix, str) + return 'g-' + context.prefix + 'nats-server' @@ -55,6 +55,7 @@ def nats_network_name(context): """ assert hasattr(context, 'prefix') assert isinstance(context.prefix, str) + return 'g-' + context.prefix + 'nats' @@ -62,8 +63,10 @@ def teardown_nats(context): assert isinstance(context.docker, DockerClient) context.docker.containers.get(nats_container_name(context)).stop() - # Output NATS logs – useful for debugging: - # print(context.docker.containers.get(nats_container_name(context)).logs().decode()) + if 'VERBOSE' in os.environ and os.environ['VERBOSE']: + print("=== NATS Logs ===") + print(context.docker.containers.get(nats_container_name(context)).logs().decode()) + print("=== END NATS Logs ===") context.docker.containers.get(nats_container_name(context)).remove() nats_network(context).remove() @@ -81,7 +84,7 @@ def nats_online(context): in network.containers ]: return # already connected - network.connect(nats_container_name(context), aliases=['nats']) + network.connect(nats_container_name(context)) restart_nats(context) @@ -103,57 +106,3 @@ def nats_network(context): assert isinstance(context.docker, DockerClient) return context.docker.networks.get(nats_network_name(context)) - -def setup_testbed(context): - """ - Build the testbed image - :param context: The context, including the Docker client. - The testbed image will be stored in the context. - """ - assert isinstance(context.docker, DockerClient) - - [image, _output] = context.docker.images.build(path='./sample-service') - context.testbed = image - - -def run_testbed(context, cmd="", disable_nats=False) -> dict: - """ - Run a command in the testbed container - :param context: the context, including the Docker client - :param cmd: the command to run. - Default is an empty string - :param disable_nats: Starts the service in the no-NATS mode - :return: The JSON output of the command - """ - assert hasattr(context, 'docker'), 'Docker client is required for this step' - assert isinstance(context.docker, DockerClient), 'Docker client must be an instance of DockerClient' - assert hasattr(context, 'testbed'), 'Testbed image is required for this step' - assert isinstance(context.testbed, Image), 'Testbed image must be an instance of Image' - assert hasattr(context, 'environment'), 'Environment variables are required for this step' - assert isinstance(context.environment, dict), 'Environment variables must be a dictionary' - assert isinstance(cmd, str), 'Command must be a string' - - env = context.environment.copy() - env['X_DISABLE_NATS'] = '1' if disable_nats else '0' - - try: - result = context.docker.containers.run( - context.testbed, - cmd, - environment=env, - auto_remove=True, - network=nats_network_name(context), - ).splitlines()[-1].decode() - - print(f"Result: {result}") - - json_result = json.loads(result) - - assert isinstance(json_result, dict) - - context.result = json_result - - return json_result - except Exception as e: - print(f"Error: {e}") - return {} diff --git a/backend-features/lib/test-result.schema.json b/backend-features/lib/test-result.schema.json new file mode 100644 index 00000000..a34844f3 --- /dev/null +++ b/backend-features/lib/test-result.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Telestion Testbed Service Result", + "type": "object", + "description": "Response object for testbed services. The last line printed by the testbed must be a JSON string following this schema.", + "properties": { + "started": { + "type": "boolean", + "description": "Whether the test started successfully." + }, + "nats_api_available": { + "type": "boolean", + "description": "Whether the NATS API is available." + }, + "nats_connected": { + "type": "boolean", + "description": "Whether the NATS connection was successful." + }, + "config": { + "type": "object", + "description": "The configuration used for the test.", + "properties": { + "SERVICE_NAME": { + "type": "string" + }, + "DATA_DIR": { + "type": "string" + }, + "NATS_URL": { + "type": "string" + }, + "NATS_USER": { + "type": "string" + }, + "NATS_PASSWORD": { + "type": "string" + } + }, + "required": [ + "SERVICE_NAME", + "DATA_DIR", + "NATS_URL" + ], + "additionalProperties": true + }, + "error": { + "type": "string", + "description": "Error message if the test failed." + }, + "env": { + "type": "object", + "description": "The environment variables used for the test.", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "started", + "nats_api_available", + "nats_connected", + "env" + ] +} \ No newline at end of file diff --git a/backend-features/lib/testbed.py b/backend-features/lib/testbed.py new file mode 100644 index 00000000..11cc4e29 --- /dev/null +++ b/backend-features/lib/testbed.py @@ -0,0 +1,86 @@ +import json +import os +from os.path import dirname + +import jsonschema +from docker.client import DockerClient +from docker.models.images import Image + +from lib.nats_container import nats_network_name + + +def setup_testbed(context): + """ + Build the testbed image + :param context: The context, including the Docker client. + The testbed image will be stored in the context. + """ + assert isinstance(context.docker, DockerClient) + + assert 'TELESTION_TESTBED_PATH' in os.environ, "Testbed path must be set (TELESTION_TESTBED_PATH)" + assert isinstance(os.environ['TELESTION_TESTBED_PATH'], str), "Testbed path must be a string" + assert os.path.exists(os.environ['TELESTION_TESTBED_PATH']), \ + f"Testbed path does not exist: {os.environ['TELESTION_TESTBED_PATH']}" + + [image, build_output] = context.docker.images.build(path=os.environ['TELESTION_TESTBED_PATH']) + if 'VERBOSE' in os.environ and os.environ['VERBOSE']: + print("=== Build Output ===") + for line in build_output: + if 'stream' in line: + print(line['stream'], end='') + print("=== END Build Output ===") + + context.testbed = image + + +def run_testbed(context, cmd="", disable_nats=False) -> dict: + """ + Run a command in the testbed container + :param context: the context, including the Docker client + :param cmd: the command to run. + Default is an empty string + :param disable_nats: Starts the service in the no-NATS mode + :return: The JSON output of the command + """ + assert hasattr(context, 'docker'), 'Docker client is required for this step' + assert isinstance(context.docker, DockerClient), 'Docker client must be an instance of DockerClient' + assert hasattr(context, 'testbed'), 'Testbed image is required for this step' + assert isinstance(context.testbed, Image), 'Testbed image must be an instance of Image' + assert hasattr(context, 'environment'), 'Environment variables are required for this step' + assert isinstance(context.environment, dict), 'Environment variables must be a dictionary' + assert isinstance(cmd, str), 'Command must be a string' + + env = context.environment.copy() + env['X_DISABLE_NATS'] = '1' if disable_nats else '0' + + try: + result = context.docker.containers.run( + context.testbed, + cmd, + environment=env, + auto_remove=True, + network=nats_network_name(context), + ).splitlines()[-1].decode() + + print(f"Result: {result}") + + json_result = json.loads(result) + + assert isinstance(json_result, dict) + validate_testbed_result(json_result) + + context.result = json_result + + return json_result + except Exception as e: + print(f"Error: {e}") + return {} + + +fp = os.path.join(dirname(__file__), 'test-result.schema.json') +with open(fp) as f: + schema = json.load(f) + + +def validate_testbed_result(result): + jsonschema.validate(result, schema) diff --git a/backend-features/requirements.txt b/backend-features/requirements.txt index fe151e39..503970f8 100644 --- a/backend-features/requirements.txt +++ b/backend-features/requirements.txt @@ -1,4 +1,6 @@ # we need this to interact with docker on the system docker==7.1.0 +# JSON schema for testbed service response validation +jsonschema==4.23.0 # we need this to execute Cucumber tests behave @ git+https://github.com/behave/behave@v1.2.7.dev6 diff --git a/backend-features/run-tests.py b/backend-features/run-tests.py new file mode 100755 index 00000000..5717a920 --- /dev/null +++ b/backend-features/run-tests.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +import os +import warnings +import argparse + +from behave.__main__ import main as run_behave + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Run the Telestion Backend Service Behavior Tests') + parser.add_argument('path', type=str, nargs='?', + help='The path to the testbed folder containing a Dockerfile. ' + 'Can also be set via the "TELESTION_TESTBED_PATH" environment variable.') + parser.add_argument('-v', '--verbose', action='store_true', + help='Verbose output for debugging purposes. Primarily for developing the tests themselves.') + args = parser.parse_args() + + if args.path: # Set from CLI (overwrites environment variable) + os.environ["TELESTION_TESTBED_PATH"] = args.path + + if args.verbose: + os.environ["VERBOSE"] = "true" + else: + # Disable NotOpenSSL warning: https://github.com/urllib3/urllib3/issues/3020 + warnings.filterwarnings("ignore", module="urllib3") + + if "TELESTION_TESTBED_PATH" not in os.environ: # No path set + warnings.warn("Please provide the path to the testbed folder containing a Dockerfile.") + exit(1) + + features_path = os.path.dirname(os.path.realpath(__file__)) + print(f"Running tests from {features_path} for testbed at {os.environ['TELESTION_TESTBED_PATH']}") + + run_behave(features_path) diff --git a/backend-features/sample-service/deno-testbed.ts b/backend-features/sample-service/deno-testbed.ts index 9eef18a5..b88366b9 100644 --- a/backend-features/sample-service/deno-testbed.ts +++ b/backend-features/sample-service/deno-testbed.ts @@ -18,7 +18,7 @@ const result: { /** * The configuration of the service. */ - config: Record; + config?: Record; /** * Details about an error that occurred during startup. */ @@ -31,7 +31,6 @@ const result: { started: false, nats_api_available: false, nats_connected: false, - config: {}, env: Deno.env.toObject(), }; diff --git a/backend-features/steps/nats.py b/backend-features/steps/nats.py index d44f82cf..5eddb5a3 100644 --- a/backend-features/steps/nats.py +++ b/backend-features/steps/nats.py @@ -1,7 +1,7 @@ from behave import given -from docker_lib import restart_nats, nats_online, nats_offline -from nats_config import update_nats_config +from lib.nats_container import restart_nats, nats_online, nats_offline +from lib.nats_config import update_nats_config @given(u'I have a NATS server running on "{host}"') diff --git a/backend-features/steps/service.py b/backend-features/steps/service.py index 54f88cd1..5c144921 100644 --- a/backend-features/steps/service.py +++ b/backend-features/steps/service.py @@ -1,7 +1,6 @@ from behave import when, then -from behave.api.pending_step import StepNotImplementedError -from docker_lib import run_testbed +from lib.testbed import run_testbed def service_env(context, disable_nats=False): @@ -58,7 +57,8 @@ def assert_service_failed(context): @then(u'the service should be configured with "{key}" set to "{expected_value}"') def assert_service_configured_with(context, key, expected_value): assert 'result' in context, 'Service must be started before checking connection' - assert 'config' in context.result, 'Service must be started before checking connection' + assert 'config' in context.result, ('Service must successfully start before checking connection. ' + 'Field "config" not set in result.') if expected_value == "undefined": assert key not in context.result['config'], 'Service must be started before checking connection' else: From 77bd3c96f52c10c716a00eb7bf9f06ebd11bd1f1 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 13 Dec 2024 22:18:02 +0100 Subject: [PATCH 12/21] Fix typos --- backend-features/steps/service.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/backend-features/steps/service.py b/backend-features/steps/service.py index 5c144921..97079a9c 100644 --- a/backend-features/steps/service.py +++ b/backend-features/steps/service.py @@ -3,15 +3,6 @@ from lib.testbed import run_testbed -def service_env(context, disable_nats=False): - assert hasattr(context, 'nats_config'), 'NATS configuration is required for this step' - assert isinstance(context.nats_config, dict), 'NATS configuration must be a dictionary' - - env = context.nats_config.copy() - env['X_DISABLE_NATS'] = '1' if disable_nats else '0' - return env - - @when(u'I start the service') def start_service(context): run_testbed(context) @@ -44,7 +35,6 @@ def assert_service_connected(context): assert 'result' in context, 'Service must be started before checking connection' assert 'nats_connected' in context.result, 'Service must be started before checking connection' assert context.result['nats_connected'], 'Service must be connected to NATS' - # raise StepNotImplementedError() @then(u'the service should fail to start') From f401dccb7f5c1388045ea9269e382417f6d48ac5 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 13 Dec 2024 23:51:47 +0100 Subject: [PATCH 13/21] Add testbed container / image specification --- backend-features/TESTBED.spec.md | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 backend-features/TESTBED.spec.md diff --git a/backend-features/TESTBED.spec.md b/backend-features/TESTBED.spec.md new file mode 100644 index 00000000..49529445 --- /dev/null +++ b/backend-features/TESTBED.spec.md @@ -0,0 +1,62 @@ +# Telestion Backend Service Behavior Testbed Container Specification + +> [!IMPORTANT] +> The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL +> NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and +"OPTIONAL" in this document are to be interpreted as described in +> [RFC 2119](https://tools.ietf.org/html/rfc2119). + +## Motivation + +Telestion services can be written in any language that supports the NATS protocol. +To ensure that all services have a common behavior, we use Gherkin to specify the behavior of the services. +Using an automated testing framework, we can ensure that all libraries for writing backend services behave as expected. + +To enable language-independent testing, tests are designed as end-to-end tests running the service as a Docker container. + +## Testbed Container + +The container MUST be self-sufficient and MUST have the service executable set as `ENTRYPOINT` so that arguments (like `--dev` or +configuration overrides) MAY be passed as `CMD` (which SHALL be empty by default). The `ENTRYPOINT` MUST run a +Telestion Backend Service ("Testbed Service") that follows the specification specified in the next section. + +> [!NOTE] Example +> +> ```dockerfile +> FROM denoland/deno:latest +> WORKDIR /app +> COPY . . +> ENTRYPOINT ["deno", "run", "--allow-all", "mod.ts"] +> ``` + +Testbed containers SHOULD do as much work as possible in the image build stage to improve testing performance as containers are recreated for each test. + +## Testbed Service + +The Testbed Service MUST be a Telestion Backend Service as defined in the Telestion Backend Service Behavior Specification. + +The Testbed Service MUST be able to parse the following environment variables: + +* `X_DISABLE_NATS` (`1` or `0`): If set to `1`, the Testbed Service SHALL be started in a non-NATS mode otherwise configurable via the service library. + +The Testbed Service MUST exit with exit code `0` even if the Service itself fails to start. + +The Testbed Service MUST output a single-line JSON object following the `lib/test-result.schema.json` JSON schema, as the last line printed to stdout before the service exits. + +> [!NOTE] Example +> +> ```txt +> import ServiceLibrary +> +> const [service] := new ServiceLibrary({ +> disable NATS: [X_DISABLE_NATS equals '1'] +> }) +> +> print as JSON: +> "env": Current Environment Variables { "KEY": "VALUE", ... } +> "started": service started? +> "error"?: error message if [service] failed to start +> "nats_api_available": NATS API available on [service]? +> "nats_connected": NATS connected? +> "config"?: Configuration accessible for [service]. Undefined if not started +> ``` From 372e3ff49d71970d1ee7de1e8be335675024190c Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Fri, 13 Dec 2024 23:53:36 +0100 Subject: [PATCH 14/21] Migrate backend-deno to new E2E testing setup --- backend-deno/Dockerfile | 13 ++ backend-deno/cucumber/README.md | 47 ------ backend-deno/cucumber/step-registry.ts | 149 ------------------ backend-deno/cucumber/steps/config.ts | 30 ---- backend-deno/cucumber/steps/nats.ts | 140 ----------------- backend-deno/cucumber/steps/start.ts | 51 ------- backend-deno/cucumber/test.ts | 202 ------------------------- backend-deno/docker-compose.yml | 10 -- backend-deno/testbed.e2e.ts | 71 +++++++++ 9 files changed, 84 insertions(+), 629 deletions(-) create mode 100644 backend-deno/Dockerfile delete mode 100644 backend-deno/cucumber/README.md delete mode 100644 backend-deno/cucumber/step-registry.ts delete mode 100644 backend-deno/cucumber/steps/config.ts delete mode 100644 backend-deno/cucumber/steps/nats.ts delete mode 100644 backend-deno/cucumber/steps/start.ts delete mode 100644 backend-deno/cucumber/test.ts delete mode 100644 backend-deno/docker-compose.yml create mode 100644 backend-deno/testbed.e2e.ts diff --git a/backend-deno/Dockerfile b/backend-deno/Dockerfile new file mode 100644 index 00000000..4402a81a --- /dev/null +++ b/backend-deno/Dockerfile @@ -0,0 +1,13 @@ +FROM denoland/deno:alpine-2.1.4 + +WORKDIR /app + +COPY deno.json deno.lock /app/ + +RUN deno install --frozen + +COPY . . + +RUN deno cache mod.ts + +ENTRYPOINT [ "deno", "run", "-A", "testbed.e2e.ts" ] diff --git a/backend-deno/cucumber/README.md b/backend-deno/cucumber/README.md deleted file mode 100644 index 99071945..00000000 --- a/backend-deno/cucumber/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Cucumber Testing Implementation - -Since we are using Deno, there is no official Cucumber implementation for Deno. -However, it's pretty easy to parse the Gherkin files and execute the steps. -This folder contains a custom implementation of Cucumber for Deno. - -## How to run the tests - -Run the following command to run the tests: - -```bash -docker compose up -``` - -In the background, this uses `deno test` with the [`test.ts`](test.ts) file as entrypoint. - -## Feature file location - -Since the feature files specify the general behavior of services, they are independent of the implementation (here: -Deno). -Therefore, the feature files are located in the repo's [`/backend-features`](../../backend-features) folder. - -## Setting up VSCode - -1. Open both folders (`/backend-deno` and `/backend-features`) in VSCode in the same workspace. -2. Install recommended extensions. -3. Open the workspace settings and add the following: - -```json -{ - "cucumberautocomplete.steps": ["backend-deno/**/*.ts"], - "cucumberautocomplete.syncfeatures": "backend-features/**/*.feature" -} -``` - -Now, you should have autocompletion for the step definitions and the feature files. - -## Unsupported Gherkin features - -As of right now, the following Gherkin features are not supported: - -- Doc Strings -- Data Tables -- Backgrounds -- Tags -- Scenario Outlines -- Examples diff --git a/backend-deno/cucumber/step-registry.ts b/backend-deno/cucumber/step-registry.ts deleted file mode 100644 index 7270749f..00000000 --- a/backend-deno/cucumber/step-registry.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { resolve } from "@std/path"; - -/** - * A step definition - * - * @see https://cucumber.io/docs/cucumber/step-definitions/?lang=javascript - */ -interface StepDefinition { - /** - * The name of the step definition that gets matched against the step name - */ - name: string; - /** - * Check if the step matches the step definition - * @param step The step name - * @returns `false` if the step does not match, otherwise an array of parameters - */ - matches: (step: string) => string[] | false; - /** - * The action to perform when the step is executed - * @param ctx A context object that can be used to share state between steps - * @param params Parameters extracted from the step name - * @returns potentially a promise that resolves when the step is done - */ - action: ( - ctx: Record, - ...params: string[] - ) => void | Promise; -} - -/** - * A registry of parameters that can be used in step definition names - */ -const paramRegistry: Record = { - "{string}": { - regex: /^"([^"]+)"$/, - }, -}; - -/** - * A registry of step definitions - */ -const stepRegistry: StepDefinition[] = []; - -/** - * Register a step definition to be used in scenarios - * @param name the name of the step definition - * @param action the action to perform when the step is executed - */ -function registerStep(name: string, action: StepDefinition["action"]) { - stepRegistry.push({ - name, - action, - matches: (step: string) => { - let regex = "^" + escapeRegExp(name) + "$"; - for (const param in paramRegistry) { - let paramRegex = paramRegistry[param].regex.source; - if (paramRegex.startsWith("^")) { - paramRegex = paramRegex.slice(1); - } - if (paramRegex.endsWith("$")) { - paramRegex = paramRegex.slice(0, -1); - } - regex = regex.replaceAll(escapeRegExp(param), paramRegex); - } - - const match = step.match(new RegExp(regex)); - - if (match) { - return match.slice(1); - } - - return false; - }, - }); -} - -/** - * Escape special characters in a string to be used in a regular expression - * @param string input - * @returns `input` with all special characters escaped - * - * @see https://stackoverflow.com/a/6969486/9276604 by user coolaj86 (CC BY-SA 4.0) - */ -function escapeRegExp(string: string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string -} - -/** - * Import all step definitions from a folder - * @param stepsFolderPath the path to the folder containing the step definitions - */ -export async function importStepDefinitions(stepsFolderPath: string) { - const files = Deno.readDirSync(stepsFolderPath); - - for (const file of files) { - const filePath = resolve(stepsFolderPath, file.name); - - if (file.isDirectory || !file.name.endsWith(".ts")) { - continue; - } - - console.debug(`Importing step file: ${filePath}`); - await import(filePath); - } - - console.debug("Steps imported"); -} - -/** - * Retrieve the action to perform when a step is executed - * @param name the name of the step - * @returns the `StepDefinition.action` function if a step definition matches the step name, otherwise `undefined` - */ -export function getStep(name: string): StepDefinition["action"] | undefined { - const step = stepRegistry.find((step) => step.matches(name)); - return step - ? (ctx) => step.action(ctx, ...step.matches(name) as string[]) - : undefined; -} - -/** - * Register a step definition to be used in scenarios - * @param name the name of the step definition. Can contain parameters. - * @param action the action to perform when the step is executed - */ -export function Given(name: string, action: StepDefinition["action"]) { - registerStep(name, action); -} - -/** - * Register a step definition to be used in scenarios - * @param name the name of the step definition. Can contain parameters. - * @param action the action to perform when the step is executed - */ -export function When(name: string, action: StepDefinition["action"]) { - registerStep(name, action); -} - -/** - * Register a step definition to be used in scenarios - * @param name the name of the step definition. Can contain parameters. - * @param action the action to perform when the step is executed - */ -export function Then(name: string, action: StepDefinition["action"]) { - registerStep(name, action); -} diff --git a/backend-deno/cucumber/steps/config.ts b/backend-deno/cucumber/steps/config.ts deleted file mode 100644 index 2aac067a..00000000 --- a/backend-deno/cucumber/steps/config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { startService } from "jsr:@wuespace/telestion"; -import { Given, Then } from "../step-registry.ts"; -import { assertEquals } from "@std/assert"; - -Given('I have an environment variable named {string} with value {string}', (_ctx, key, value) => { - Deno.env.set(key, value); -}); - -Given('I have the basic service configuration', () => { - Object.keys(Deno.env.toObject()).forEach((key) => { - Deno.env.delete(key); - }); - Deno.env.set('NATS_URL', 'localhost:4222'); - Deno.env.set('DATA_DIR', '/tmp/deno-gherkin'); - Deno.env.set('SERVICE_NAME', 'deno-gherkin'); -}); - -Then('the service should be configured with {string} set to {string}', (ctx, key, shouldBe) => { - const theService = ctx.service as Awaited>; - - const value = theService.config[key]; - - assertEquals((value ?? 'undefined').toString(), shouldBe); -}); - -Given('I have no service configuration', () => { - Object.keys(Deno.env.toObject()).forEach((key) => { - Deno.env.delete(key); - }); -}) diff --git a/backend-deno/cucumber/steps/nats.ts b/backend-deno/cucumber/steps/nats.ts deleted file mode 100644 index 724471d1..00000000 --- a/backend-deno/cucumber/steps/nats.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { ConnectionOptions, NatsConnection } from "@nats-io/nats-core"; -import { Given, Then } from "../step-registry.ts"; -import { assert, assertEquals } from "@std/assert"; - -/** - * A mock NATS client that can be used to test services that use NATS. - */ -export class NatsMock { - /** - * A list of subscriptions that have been made. - */ - subscriptions: unknown[][] = []; - /** - * Whether the client is connected to NATS. - */ - public isConnected = false; - /** - * Whether the client should fail to connect to NATS. - */ - public isOffline = false; - /** - * Whether the server requires authentication. - */ - public requiresAuth = false; - /** - * A map of usernames to passwords. - */ - public users = new Map(); - - /** - * Create a new mock NATS client. - * @param server The server that the client can connect to. - */ - constructor(public server = "localhost:4222") { - } - - /** - * Register a user with a password. This user can then be used to connect to NATS when `requiresAuth` is true. - * @param username valid username - * @param password valid password for the username - * - * @see {@link requiresAuth} - */ - public registerUser(username: string, password: string) { - this.users.set(username, password); - } - - /** - * A mock NATS connection that can be used to test services that use NATS. - * - * Gets returned by {@link connect} upon successful connection. - * - * Can be used to assert that the service received the correct NATS connection object. - */ - public readonly connection = { - subscribe: (...args: unknown[]) => { - this.subscriptions.push(args); - }, - }; - - connect(options: ConnectionOptions) { - if (this.server !== options.servers) { - return Promise.reject(new Error("Invalid server")); - } - - if (this.isOffline) { - return Promise.reject(new Error("NATS is offline")); - } - - if (this.requiresAuth && (!options.user || !options.pass)) { - return Promise.reject(new Error("NATS requires authentication")); - } - - if (this.requiresAuth && this.users.get(options.user!) !== options.pass) { - return Promise.reject(new Error("Invalid credentials")); - } - - this.isConnected = true; - - return Promise.resolve(this.connection); - } -} - -Given( - "I have a NATS server running on {string}", - (ctx, url) => { - ctx.nats = new NatsMock(url); - }, -); - -Given( - "{string} is a NATS user with password {string}", - (ctx, username, password) => { - if (!ctx.nats) { - throw new Error("No NATS mock available"); - } - - const nats = ctx.nats as NatsMock; - nats.registerUser(username, password); - }, -); - -Given("the NATS server requires authentication", (ctx) => { - if (!ctx.nats) { - throw new Error("No NATS mock available"); - } - - const nats = ctx.nats as NatsMock; - nats.requiresAuth = true; -}); - -Given("the NATS server is offline", (ctx) => { - if (!ctx.nats) { - throw new Error("No NATS mock available"); - } - - const nats = ctx.nats as NatsMock; - nats.isOffline = true; -}); - -Then("the service should connect to NATS", (ctx) => { - const nats = ctx.nats as NatsMock; - assert(nats); - assert(nats.isConnected); -}); - -Then("the service should not connect to NATS", (ctx) => { - const nats = ctx.nats as NatsMock; - assert(nats); - assert(!nats.isConnected); -}); - -Then("the NATS connection API should be available to the service", (ctx) => { - const nats = ctx.nats as NatsMock; - assert(nats); - const service = ctx.service as { nc: unknown }; - assert(service); - assert(service.nc); - assertEquals(service.nc, nats.connection as NatsConnection); -}); diff --git a/backend-deno/cucumber/steps/start.ts b/backend-deno/cucumber/steps/start.ts deleted file mode 100644 index 62742f46..00000000 --- a/backend-deno/cucumber/steps/start.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { startService } from "jsr:@wuespace/telestion"; -import { Then, When } from "../step-registry.ts"; -import { assert, assertRejects } from "@std/assert"; - -When("I start the service", async (ctx) => { - if (!ctx.nats) { - throw new Error("No NATS mock available"); - } - - ctx.service = await startService({ - natsMock: ctx.nats - }); -}); - -When("I start the service with {string}", async (ctx, arg) => { - if (!ctx.nats) { - throw new Error("No NATS mock available"); - } - - ctx.service = await startService({ - natsMock: ctx.nats, - overwriteArgs: arg.split(/\s+/), - }); -}); - -When("I start the service without NATS", async (ctx) => { - ctx.service = await startService({ - nats: false, - }); -}); - -When('I start the service with {string} without NATS', async (ctx, arg) => { - // Write code here that turns the phrase above into concrete actions - ctx.service = await startService({ - nats: false, - overwriteArgs: arg.split(/\s+/), - }); -}) - -Then("the service should fail to start", async (ctx) => { - await assertRejects(() => - startService({ - nats: !!ctx.nats, - natsMock: ctx.nats, - }) - ); -}); - -Then("the service should start", (ctx) => { - assert(ctx.service); -}); diff --git a/backend-deno/cucumber/test.ts b/backend-deno/cucumber/test.ts deleted file mode 100644 index 58e5b439..00000000 --- a/backend-deno/cucumber/test.ts +++ /dev/null @@ -1,202 +0,0 @@ -/*! - * Cucumber Implementation for Deno - * - * Call using: `deno test -A cucumber/test.ts --features [feature folder] --steps [steps folder]` - * - * Arguments: - * --features [feature folder] The folder containing the feature files, relative to the current working directory - * --steps [steps folder] The folder containing the step definitions, relative to the current working directory - * - * Copyright (2023) WüSpace e. V. - * Author: Zuri Klaschka - * - * MIT License (MIT) - */ - -import { parseArgs } from "@std/cli"; -import { getStep, importStepDefinitions } from "./step-registry.ts"; -import { resolve } from "@std/path"; -import { AssertionError } from "@std/assert"; - -/// Determine steps and features folder from command line arguments - -const { steps, features } = parseArgs(Deno.args); -const stepsFolderPath = resolve(Deno.cwd(), steps); -const featuresFolderPath = resolve(Deno.cwd(), features); - -/// Import all features - -const featureDefinitions = []; - -for await (const potentialFeature of Deno.readDir(featuresFolderPath)) { - const isFeatureFile = potentialFeature.isFile && - potentialFeature.name.endsWith(".feature"); - if (isFeatureFile) { - const filePath = resolve(featuresFolderPath, potentialFeature.name); - featureDefinitions.push( - Deno.readTextFileSync( - filePath, - ), - ); - } -} - -/// Import all step definitions - -await importStepDefinitions(stepsFolderPath); - -/// Run all features - -for (const featureFile of featureDefinitions) { - const feature = parseFeature(featureFile); - - Deno.test(`Feature: ${feature.name}`, async (t) => { - for (const scenario of feature.scenarios) { - await t.step(`Scenario: ${scenario.name}`, async (t) => { - await runScenario(scenario, t); - }); - } - }); -} - -/** - * Run a scenario in a deno testing context - * @param scenario the scenario to run - * @param t the text context. Required to keep async steps in order - */ -async function runScenario(scenario: Scenario, t: Deno.TestContext) { - /** - * The context object passed to all steps - * - * This is used to share data between steps - */ - const ctx = {}; - for (const step of scenario.steps) { - const stepAction = getStep(step.name); - if (!stepAction) { - throw new AssertionError(`Step not found: ${step.name}`); - } - - await t.step(`${step.type} ${step.name}`, async () => { - await stepAction(ctx); - }); - } -} - -/** - * A Gherkin feature - */ -interface Feature { - /** - * The name of the feature - */ - name: string; - /** - * The scenarios in the feature - */ - scenarios: Scenario[]; -} - -/** - * A Gherkin scenario - */ -interface Scenario { - /** - * The name of the scenario - */ - name: string; - /** - * The steps in the scenario - */ - steps: Step[]; -} - -/** - * A Gherkin step - */ -interface Step { - /** - * The type of the step - */ - type: 'Given' | 'When' | 'Then'; - /** - * The name of the step - */ - name: string; -} - -/** - * Parse a Ghrekin feature - * @param featureCode The Ghrekin feature code - * @returns The parsed feature - */ -function parseFeature(featureCode: string): Feature { - const lines = extractLines(); - - let featureName = ""; - const scenarios: Scenario[] = []; - - for (const line of lines) { - if (line.startsWith("Feature:")) { - featureName = line.replace("Feature:", "").trim(); - continue; - } - - if (line.startsWith("Scenario:")) { - scenarios.push({ - name: line.replace("Scenario:", "").trim(), - steps: [], - }); - continue; - } - - const scenario = scenarios.at(-1); - if (!scenario) { - continue; - } - - for (const keyword of ["Given", "When", "Then"] satisfies Step['type'][]) { - if (line.startsWith(keyword + " ")) { - scenario.steps.push({ - type: keyword, - name: line.replace(keyword, "").trim(), - }); - continue; - } - } - - for (const keyword of ["And", "But", "*"]) { - if (line.startsWith(keyword + " ")) { - if (scenario.steps.length === 0) { - throw new Error( - `Step "${keyword}" is not allowed in the first step of a scenario.`, - ); - } - scenario.steps.push({ - type: scenario.steps[scenario.steps.length - 1].type, - name: line.replace("And", "").trim(), - }); - continue; - } - } - } - - return { - name: featureName, - scenarios, - }; - - function extractLines() { - featureCode = featureCode.replace(/\r\n/g, "\n") // Normalize line endings - .replace(/\r/g, "\n") // Normalize line endings - .replace(/ {2,}/g, " ") // Normalize whitespace - .replace(/\n{2,}/g, "\n") // Normalize multiple line endings - .replace(/\t/g, " ") // Normalize tabs - .replace(/* indented */ /^ {2,}/gm, ""); // Remove indentation - - const lines = featureCode.split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - return lines; - } -} diff --git a/backend-deno/docker-compose.yml b/backend-deno/docker-compose.yml deleted file mode 100644 index 8a930e8b..00000000 --- a/backend-deno/docker-compose.yml +++ /dev/null @@ -1,10 +0,0 @@ -services: - backend-deno: - image: denoland/deno:2.1.2 - working_dir: /app - command: [ "test", "--allow-all", "./cucumber/test.ts", "--", - "--features", "/features", - "--steps", "/app/cucumber/steps", ] - volumes: - - .:/app - - ../backend-features:/features diff --git a/backend-deno/testbed.e2e.ts b/backend-deno/testbed.e2e.ts new file mode 100644 index 00000000..b88366b9 --- /dev/null +++ b/backend-deno/testbed.e2e.ts @@ -0,0 +1,71 @@ +import { startService } from "jsr:@wuespace/telestion"; + +const disableNats = Deno.env.get("X_DISABLE_NATS") === "1"; + +const result: { + /** + * Whether the service has been started successfully. + */ + started: boolean; + /** + * Whether the NATS API is available. + */ + nats_api_available: boolean; + /** + * Whether the service is connected to the NATS server. + */ + nats_connected: boolean; + /** + * The configuration of the service. + */ + config?: Record; + /** + * Details about an error that occurred during startup. + */ + error?: string; + /** + * The environment variables of the service. + */ + env: Record; +} = { + started: false, + nats_api_available: false, + nats_connected: false, + env: Deno.env.toObject(), +}; + +try { + if (disableNats) { + const res = await startService({ nats: false }); + result.config = res.config; // We have a config + result.started = true; // We have started the service + try { + // This should throw an error as NATS is disabled + const nats = res.nc; + result.nats_api_available = true; + result.nats_connected = nats.isClosed() === false; + } catch { /**/ } + } else { + const res = await startService(); + result.started = true; // We have started the service + result.config = res.config; // We have a config + + try { + const nats = res.nc; // This should not throw an error – NATS is enabled + result.nats_api_available = true; + result.nats_connected = nats.isClosed() === false; + } catch { /**/ } + } +} catch (e) { + // An error occurred during startup. result.started is still false. + // Let's add some more details about the error in case it wasn't expected. + if (e instanceof Error) { + result.error = e.message; + } else { + result.error = "Unknown error"; + } +} finally { + // No matter what happens, the last printed line must be the JSON result string and the script must exit with code 0. + console.log(JSON.stringify(result)); + Deno.exit(); // Important – otherwise the script will keep running +} From 7a3c73efaffd5b56f532df1f5b53baba8049727a Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sat, 14 Dec 2024 00:55:04 +0100 Subject: [PATCH 15/21] Update Deno CI --- .github/workflows/backend-deno-ci.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/backend-deno-ci.yml b/.github/workflows/backend-deno-ci.yml index fd765886..3c8b7440 100644 --- a/.github/workflows/backend-deno-ci.yml +++ b/.github/workflows/backend-deno-ci.yml @@ -1,6 +1,6 @@ name: Backend Deno CI -on: [ push, pull_request ] +on: [push, pull_request] defaults: run: @@ -13,8 +13,11 @@ jobs: steps: - name: Checkout 📥 uses: actions/checkout@v3.6.0 - - name: Run tests 🛃 - run: docker compose up --abort-on-container-exit - - name: Stop containers 🛑 - if: always() - run: docker compose down + - name: Setup Virtual Environment 🛠️ + working-directory: ./backend-features + run: |- + ./tools/setup.sh + . ./.venv/bin/activate + - name: Run Tests 🧪 + working-directory: ./backend-deno + run: ../backend-features/run-tests.py . From 047406feb9dc90a71fe5726441df9b59a2bc4bdd Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sat, 14 Dec 2024 00:56:40 +0100 Subject: [PATCH 16/21] Try to fix Deno CI --- .github/workflows/backend-deno-ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/backend-deno-ci.yml b/.github/workflows/backend-deno-ci.yml index 3c8b7440..34c2e232 100644 --- a/.github/workflows/backend-deno-ci.yml +++ b/.github/workflows/backend-deno-ci.yml @@ -14,10 +14,8 @@ jobs: - name: Checkout 📥 uses: actions/checkout@v3.6.0 - name: Setup Virtual Environment 🛠️ - working-directory: ./backend-features run: |- - ./tools/setup.sh - . ./.venv/bin/activate + ./backend-features/tools/setup.sh + . ./backend-features/.venv/bin/activate - name: Run Tests 🧪 - working-directory: ./backend-deno - run: ../backend-features/run-tests.py . + run: ./backend-features/run-tests.py ./backend-deno From 50b2e38ac05b431caa826c00816f8951c9f3c26e Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sat, 14 Dec 2024 00:58:36 +0100 Subject: [PATCH 17/21] Fix paths in backend-deno CI workflow --- .github/workflows/backend-deno-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/backend-deno-ci.yml b/.github/workflows/backend-deno-ci.yml index 34c2e232..a7981561 100644 --- a/.github/workflows/backend-deno-ci.yml +++ b/.github/workflows/backend-deno-ci.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v3.6.0 - name: Setup Virtual Environment 🛠️ run: |- - ./backend-features/tools/setup.sh - . ./backend-features/.venv/bin/activate + ../backend-features/tools/setup.sh + . ../backend-features/.venv/bin/activate - name: Run Tests 🧪 - run: ./backend-features/run-tests.py ./backend-deno + run: ../backend-features/run-tests.py From 710b016d355faf99995da990ae7d6f199c7f657f Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sat, 14 Dec 2024 00:59:22 +0100 Subject: [PATCH 18/21] Update CI workflow to use setup-venv.sh for virtual environment setup --- .github/workflows/backend-deno-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-deno-ci.yml b/.github/workflows/backend-deno-ci.yml index a7981561..4fba9091 100644 --- a/.github/workflows/backend-deno-ci.yml +++ b/.github/workflows/backend-deno-ci.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v3.6.0 - name: Setup Virtual Environment 🛠️ run: |- - ../backend-features/tools/setup.sh + ../backend-features/tools/setup-venv.sh . ../backend-features/.venv/bin/activate - name: Run Tests 🧪 run: ../backend-features/run-tests.py From 62216f9b9660c2602add2a29734b36f2f700c86e Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sat, 14 Dec 2024 01:00:28 +0100 Subject: [PATCH 19/21] Activate virtual environment before running tests in backend-deno CI workflow --- .github/workflows/backend-deno-ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/backend-deno-ci.yml b/.github/workflows/backend-deno-ci.yml index 4fba9091..b6d1cbef 100644 --- a/.github/workflows/backend-deno-ci.yml +++ b/.github/workflows/backend-deno-ci.yml @@ -16,6 +16,7 @@ jobs: - name: Setup Virtual Environment 🛠️ run: |- ../backend-features/tools/setup-venv.sh - . ../backend-features/.venv/bin/activate - name: Run Tests 🧪 - run: ../backend-features/run-tests.py + run: |- + . ../backend-features/.venv/bin/activate + ../backend-features/run-tests.py From 6663147cc9019300dda751fd0588394c6333bd68 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sat, 14 Dec 2024 01:01:25 +0100 Subject: [PATCH 20/21] Add verbose flag to run-tests.py in backend-deno CI workflow --- .github/workflows/backend-deno-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-deno-ci.yml b/.github/workflows/backend-deno-ci.yml index b6d1cbef..6f130ed8 100644 --- a/.github/workflows/backend-deno-ci.yml +++ b/.github/workflows/backend-deno-ci.yml @@ -19,4 +19,4 @@ jobs: - name: Run Tests 🧪 run: |- . ../backend-features/.venv/bin/activate - ../backend-features/run-tests.py + ../backend-features/run-tests.py -v . From fd27c3f2a12f98427aabb1e748144130744d4b08 Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sat, 14 Dec 2024 18:23:59 +0100 Subject: [PATCH 21/21] Remove obsolete Gherkin Linter --- .github/workflows/backend-features-lint.yml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .github/workflows/backend-features-lint.yml diff --git a/.github/workflows/backend-features-lint.yml b/.github/workflows/backend-features-lint.yml deleted file mode 100644 index c23af86b..00000000 --- a/.github/workflows/backend-features-lint.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Backend Features Lint - -on: [ push, pull_request ] - -defaults: - run: - working-directory: ./backend-features - -jobs: - lint: - name: Gherkin Lint - runs-on: ubuntu-latest - steps: - - name: Checkout 📥 - uses: actions/checkout@v3.6.0 - - name: Run Linter 📑 - run: docker compose run lint