diff --git a/docs/source/beast_lab.rst b/docs/source/beast_lab.rst new file mode 100644 index 0000000..1e23dde --- /dev/null +++ b/docs/source/beast_lab.rst @@ -0,0 +1,105 @@ + +======================== +The ArgoBEAST Lab (BETA) +======================== + +The **ArgoBEAST Lab** is a standardised, containerised testing environment. It provides a consistent execution space that includes a **Selenium Grid** and a dedicated **ArgoBEAST Runner**. + +This is the recommended way to run tests in environments like **WSL2**, **GitPod**, or **CI/CD pipelines** where local browser management is often problematic. + +Commands Overview +================= + ++---------------------------+---------------+------------------------------------------------------------+ +| Command | Action | Description | ++===========================+===============+============================================================+ +| ``argobeast build lab`` | **Construct** | Generates the Docker infrastructure and local directory. | ++---------------------------+---------------+------------------------------------------------------------+ +| ``argobeast open lab`` | **Enter** | Boots the Selenium Grid, updates config, and enters shell. | ++---------------------------+---------------+------------------------------------------------------------+ +| ``argobeast close lab`` | **Sanitise** | Shuts down the environment and frees up system resources. | ++---------------------------+---------------+------------------------------------------------------------+ + +1. argobeast build lab +====================== + +This command prepares the physical "equipment" for your testing environment. It creates a dedicated ``argobeast_lab/`` directory in your project root. + +* **Infrastructure**: Generates a custom ``argobeast.dockerfile`` and ``argobeast.dockercompose.yml``. +* **Isolation**: Uses a **Debian-slim** base to ensure compatibility across WSL, Linux, and Mac. +* **Persistence**: The Lab is built with an ``argouser`` to prevent file permission issues on your host machine. + +.. note:: + Run this once per project, or whenever you need to reset your Lab equipment. + +2. argobeast open lab +===================== + +The primary gateway to your execution environment. When you run this command, ArgoBEAST performs a "System Pre-flight Check": + +#. **Integrity Check**: Ensures Lab files exist. +#. **Config Sync**: Automatically scans your ``config/driver.yml``. If ``remote_url`` is missing or commented out, the Lab will wire it up for you. +#. **Engine Start**: Spins up a **Standalone Chrome** container and the **ArgoBEAST Runner**. +#. **Infiltration**: Executes an interactive session, placing you at the prompt: ``[argobeast lab]: /app #`` + +**Inside the Lab:** + +Once the doors swing open, you are in a pure Python environment. Simply run: + +.. code-block:: bash + + behave + +to execute your tests. The Lab's internal network allows seamless communication with the Selenium Grid, so your tests will run as if they were on a local machine. + +You can run any argobeast command from within the lab, but remember that the Lab is designed to be a self-contained environment. If you need to make changes to your host machine's configuration or files, exit the Lab first with: + +.. code-block:: bash + + exit + +3. argobeast close lab +====================== + +When testing is complete, use this command to "turn off the lights." It performs a ``docker compose down``, ensuring no orphaned containers are eating your RAM. + +.. warning:: + For safety, you cannot close the Lab from *inside* the Lab. Type ``exit`` to return to your host machine first. + +Technical Specifications +======================== + +The Selenium Grid (VNC) +----------------------- + +The Lab includes a built-in "Observation Window." While the Lab is open, you can watch your tests execute in real-time: + +* **URL**: ``http://localhost:7900`` +* **Features**: Live browser interaction and debugging via NoVNC. + +Environment Variables +--------------------- + +The Lab injects the following into your session: + +* ``IS_IN_LAB=True``: Used by the framework to prevent recursive loops. +* ``ARGO_ENV=container``: Can be used in your code to toggle specific behaviors. +* ``SE_REMOTE_URL``: Pre-configured to point to the internal Grid service. + +Troubleshooting +=============== + +"The Lab is Locked" (Permission Denied) +--------------------------------------- + +If you encounter a Docker socket error on Linux/WSL, your user needs permission to handle the equipment. Run: + +.. code-block:: bash + + sudo usermod -aG docker $USER + newgrp docker + +"Missing Requirements" +---------------------- + +The Lab attempts to install your host's ``requirements.txt`` upon build. If you add new dependencies to your project, you must run ``argobeast build lab`` again to "restock" the Lab's libraries. diff --git a/docs/source/index.rst b/docs/source/index.rst index 5d7661b..6fb80d1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,6 +14,7 @@ Welcome to ArgoBEAST's documentation! ArgoBEAST is a powerful Python-based test :caption: Contents: getting_started + beast_lab pages actions steps diff --git a/pyproject.toml b/pyproject.toml index d062fff..b6a754c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "argobeast" -version = "2.1.4.post1" +version = "2.2.4b1" description = "A Python-based test automation framework for Behave with a clean Page Object architecture and CLI scaffolding." readme = "README.md" requires-python = ">=3.10" diff --git a/requirements.txt b/requirements.txt index c6a5a3f..5b1e2e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,7 @@ Pygments==2.20.0 pyproject_hooks==1.2.0 PySocks==1.7.1 pytest==9.0.3 -python-dotenv==1.2.1 +python-dotenv==1.2.2 PyYAML==6.0.3 requests==2.33.0 roman-numerals==4.1.0 diff --git a/requirements_dev.txt b/requirements_dev.txt index 8f2c0e3..fb4f3b1 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,6 +1,6 @@ behave==1.2.6 deepmerge==2.0 -python-dotenv==1.2.1 +python-dotenv==1.2.2 PyYAML==6.0.3 pytest==9.0.3 selenium==4.40.0 diff --git a/src/argo_beast/cli/beast_lab.py b/src/argo_beast/cli/beast_lab.py new file mode 100644 index 0000000..ce6f2f0 --- /dev/null +++ b/src/argo_beast/cli/beast_lab.py @@ -0,0 +1,160 @@ +import os +import subprocess +from .helpers import ensure_dir, ok, warn, error +from .templates import DOCKERFILE_TEMPLATE, DOCKER_COMPOSE_TEMPLATE + + +def _update_driver_config(): + config_path = "config/driver.yml" + remote_url = "http://selenium-grid:4444/wd/hub" + + if not os.path.exists(config_path): + warn("No driver.yml found, skipping lab configuration.") + warn( + "if you are using a custom configuration, you can add the following to your driver.yml \n" + "to connect to the lab Grid:\n" + ) + warn(f'remote_url: "{remote_url}"\n') + return True + + with open(config_path, "r", encoding="utf-8") as f: + content = f.readlines() + + # Check if we've already added the lab config + if any("remote_url:" in line for line in content): + if any(line.strip().startswith("#") and remote_url in line for line in content): + ok( + "The lab configuration is already present but commented out in driver.yml." + ) + ok("Uncomment the remote_url line to connect to the lab Grid.") + elif any(f'remote_url: "{remote_url}"' in line for line in content): + ok("The lab configuration looks ok in driver.yml.") + return True + else: + warn( + "remote_url configuration already exists in driver.yml but it does not match the lab Grid URL.\n" + f"To use the lab, please update the remote_url in your driver.yml to point to {remote_url}. \n" + "or remove the existing remote_url configuration to allow argobeast to add the correct " + "one automatically." + ) + return False + + ok("Wiring up the driver.yml to the lab Grid...") + + # We append it to the end or modify the specific key + # Adding it as a commented-out toggle is often the friendliest way + with open(config_path, "a", encoding="utf-8") as f: + f.write("\n# Added by argobeast build lab\n") + f.write(f'remote_url: "{remote_url}"\n') + return True + + +def build_lab(): + + if os.environ.get("IS_IN_LAB"): + warn("You are already in the lab! No need to build it again.!") + return + + ensure_dir("argobeast_lab") + if os.path.exists("argobeast_lab/argobeast.dockerfile"): + warn("The ArgoBEAST lab already exists") + else: + ok("Adding furniture and equipment...") + with open("argobeast_lab/argobeast.dockerfile", "w", encoding="utf-8") as f: + f.write(DOCKERFILE_TEMPLATE) + if os.path.exists("argobeast_lab/argobeast.dockercompose.yml"): + warn("The ArgoBEAST lab already exists") + else: + warn("Somebody dropped a test tube and it broke!") + ok("replacing equipment...") + with open( + "argobeast_lab/argobeast.dockercompose.yml", "w", encoding="utf-8" + ) as f: + f.write(DOCKER_COMPOSE_TEMPLATE) + ok("cleaning up the mess...") + ok("The ArgoBEAST lab is ready to use! run `argobeast open lab` to get started") + + +def open_lab(): + + if os.environ.get("IS_IN_LAB"): + warn("You are already in the lab!") + return + if not os.path.exists("argobeast_lab/argobeast.dockerfile") or not os.path.exists( + "argobeast_lab/argobeast.dockercompose.yml" + ): + warn( + "The lab is either not built or there are missing files. " + "Please run `argobeast build lab` first." + ) + return + + if not _update_driver_config(): + return + + ok("Setting things up and opening the lab door...") + cmd = [ + "docker", + "compose", + "-f", + "argobeast_lab/argobeast.dockercompose.yml", + "up", + "-d", + ] + cmd_enter = ["docker", "exec", "-it", "argobeast-runner", "/bin/bash"] + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + ok("The door to the lab swings open...") + try: + subprocess.run(cmd_enter, check=True) + except subprocess.CalledProcessError as e: + error("Error entering the lab: " + str(e.stderr)) + warn( + "The door is open but something went wrong when you tried to enter. " + "Please ensure the container is running and try again." + ) + except subprocess.CalledProcessError as e: + error_msg = e.stderr.lower() + + if "permission denied" in error_msg: + warn("[SYSTEM] Permission Denied: Cannot connect to the Docker daemon.") + ok( + "To fix this, please run the following commands and restart your terminal:" + ) + print("\n sudo usermod -aG docker $USER") + print(" newgrp docker\n") + else: + error("Error details: " + str(e.stderr)) + warn( + "The door is jammed shut, please ensure you have Docker installed and running and then try again." + ) + warn( + "If you would prefer to use a different container or VM solution, " + "you can use the provided Dockerfile to build your own image and set up the lab environment manually." + ) + return + + +def close_lab(): + if os.environ.get("IS_IN_LAB"): + ok( + "You must leave the lab before you can close it. " + "Type `exit` to leave and then run this command again." + ) + return + cmd = [ + "docker", + "compose", + "-f", + "argobeast_lab/argobeast.dockercompose.yml", + "down", + ] + try: + ok("Shutting down the lab and cleaning up...") + subprocess.run(cmd, capture_output=True, text=True, check=True) + ok("The lab is now closed. See you next time!") + except subprocess.CalledProcessError as e: + error("Error closing the lab: " + str(e.stderr)) + warn( + "Something went wrong while trying to close the lab. Please ensure Docker is running and try again." + ) diff --git a/src/argo_beast/cli/helpers.py b/src/argo_beast/cli/helpers.py index 5052218..cbc089e 100644 --- a/src/argo_beast/cli/helpers.py +++ b/src/argo_beast/cli/helpers.py @@ -24,24 +24,26 @@ def ensure_dir(directory): Path(directory).mkdir(parents=True, exist_ok=True) -def ok(log: str): - string = f"{GREEN}[OK]{RESET} {log}" - print(string) - - -def warn(log: str): - string = f"{YELLOW}[WARN]{RESET} {log}" - print(string) - - -def info(log: str): - string = f"{PURPLE}[INFO]{RESET} {log}" - print(string) - - -def error(log: str): - string = f"{RED}[ERROR]{RESET} {log}" - print(string) +def _beast_print(prefix, color, *args, **kwargs): + prefix_str = f"{color}[{prefix}]{RESET}" + if args: + args = list(args) + args[0] = f"{prefix_str} {args[0]}" + else: + args = [prefix_str] + print(*args, **kwargs) + +def error(*args, **kwargs): + _beast_print("ERROR", RED, *args, **kwargs) + +def ok(*args, **kwargs): + _beast_print("OK", GREEN, *args, **kwargs) + +def warn(*args, **kwargs): + _beast_print("WARN", YELLOW, *args, **kwargs) + +def info(*args, **kwargs): + _beast_print("INFO", PURPLE, *args, **kwargs) def get_class_name(name): diff --git a/src/argo_beast/cli/main.py b/src/argo_beast/cli/main.py index 0754272..1f04371 100644 --- a/src/argo_beast/cli/main.py +++ b/src/argo_beast/cli/main.py @@ -3,6 +3,7 @@ from .create import create, create_all, init from .helpers import warn, info, ARGO_BEAST from .generate_feature_docs import generate_rst_documentation +from .beast_lab import build_lab, open_lab, close_lab def fix_windows_encoding(): @@ -16,6 +17,43 @@ def fix_windows_encoding(): sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8") +def _less_than_three_args(args): + if args[0] == "init": + print(ARGO_BEAST) + init() + elif args[0] == "help": + print( + "Usage: argobeast \n\n" + "Commands:\n" + " create - Create a new page, actions, feature, or steps file.\n" + " init - Initialize a new ArgoBEAST project in the current directory.\n" + " hello - Display a welcome message and introduction to ArgoBEAST.\n" + " generate-docs - Generate documentation for all features in the project.\n\n" + " build lab - Build the ArgoBEAST lab Docker image.\n\n" + "Types (for create command):\n" + " page - Create a new Page Object class.\n" + " actions - Create a new Actions class.\n" + " feature - Create a new feature file with scenarios.\n" + " steps - Create a new steps definition file.\n" + " all - Create all of the above with the given name." + ) + elif args[0] == "hello": + print(ARGO_BEAST) + print( + " ### Welcome to ArgoBEAST! ###" + "\n___________________________________________________________________" + "\nA Python-based test automation framework for web applications, " + "\nbuilt on Behave, Selenium, and a clean Page Object Model. " + "\nGet started by running 'argobeast init' to set up your first project." + ) + elif args[0] == "generate-docs": + generate_rst_documentation(args) + else: + info( + "Usage: argobeast create \n To initiate a new project try argobeast init" + ) + + def main(): """ Main CLI entry point @@ -29,27 +67,27 @@ def main(): "Usage: argobeast create \n To initiate a new project try argobeast init" ) return - if len(args) < 3: - if args[0] == "init": - print(ARGO_BEAST) - init() - elif args[0] == "hello": - print(ARGO_BEAST) - print( - " ### Welcome to ArgoBEAST! ###" - "\n___________________________________________________________________" - "\nA Python-based test automation framework for web applications, " - "\nbuilt on Behave, Selenium, and a clean Page Object Model. " - "\nGet started by running 'argobeast init' to set up your first project." - ) - elif args[0] == "generate-docs": - generate_rst_documentation(args) + + if len(args) > 1 and args[1] == "lab": + if args[0] == "build": + build_lab() + elif args[0] == "open": + open_lab() + elif args[0] in ["close","stop"]: + close_lab() else: info( - "Usage: argobeast create \n To initiate a new project try argobeast init" + "Usage: argobeast lab\n" + " build lab - Build the ArgoBEAST lab Docker image.\n" + " open lab - Open the ArgoBEAST lab environment (requires Docker).\n" + " close lab - Close the ArgoBEAST lab environment." ) return + if len(args) < 3: + _less_than_three_args(args) + return + if len(args) > 3: info( "Usage: argobeast create \n To initiate a new project try argobeast init" diff --git a/src/argo_beast/cli/templates.py b/src/argo_beast/cli/templates.py index bd09252..21f70fc 100644 --- a/src/argo_beast/cli/templates.py +++ b/src/argo_beast/cli/templates.py @@ -50,7 +50,7 @@ def step_example_action(context,name): # Example: actions.login("user", "pass") pass -@then("I should see an example result") +@then("I should see an expected result") def step_assert_result(context): actions = context.app.get_actions({ClassName}Actions) # Example: assert actions.page.is_visible(actions.page.SUCCESS_MESSAGE) @@ -137,3 +137,44 @@ def step_assert_result(context): When I click the "Continue as Guest" link Then I should be on the home page """ + +DOCKERFILE_TEMPLATE = """ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt* . +RUN pip install --no-cache-dir argobeast \ + && if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi +RUN useradd -m argouser +RUN echo "export PS1='[argobeast lab]: \\w \\$ '" >> /home/argouser/.bashrc +USER argouser +ENV PS1="[argobeast lab]: " \ + IS_IN_LAB=True +""" + +DOCKER_COMPOSE_TEMPLATE = """ +services: + selenium-grid: + image: selenium/standalone-chrome:latest + ports: + - "4444:4444" + - "7900:7900" # NoVNC - lets users WATCH the tests in a browser + shm_size: 2gb + + argobeast_runner: + image: argobeast_runner + container_name: argobeast-runner + build: + context: . + dockerfile: argobeast.dockerfile + volumes: + - .:/app + working_dir: /app + environment: + - SE_REMOTE_URL=http://selenium-grid:4444/wd/hub + - ARGO_ENV=container + - IS_IN_LAB=True + depends_on: + - selenium-grid + tty: true + stdin_open: true +""" diff --git a/tests/unit/test_beast_lab.py b/tests/unit/test_beast_lab.py new file mode 100644 index 0000000..55d1d3b --- /dev/null +++ b/tests/unit/test_beast_lab.py @@ -0,0 +1,104 @@ +from unittest.mock import patch, mock_open, MagicMock +import subprocess +import os +from argo_beast.cli.beast_lab import ( + build_lab, + open_lab, + close_lab, + _update_driver_config, +) +from argo_beast.cli.templates import DOCKERFILE_TEMPLATE + + +## 1. Test Building the Lab +@patch("os.path.exists") +@patch("argo_beast.cli.beast_lab.ensure_dir") +@patch("builtins.open", new_callable=mock_open) +def test_build_lab_creates_files(mock_file, mock_ensure_dir, mock_exists): + # Setup: Files don't exist yet + mock_exists.return_value = False + + build_lab() + + # Verify directory was checked/created + mock_ensure_dir.assert_called_with("argobeast_lab") + + # Verify Dockerfile and Compose were written + # 2 files + potentially directory creation check + assert mock_file.call_count >= 2 + mock_file().write.assert_any_call(DOCKERFILE_TEMPLATE) + + +## 2. Test Opening the Lab (Success Path) +@patch("os.path.exists") +@patch("subprocess.run") +@patch("argo_beast.cli.beast_lab._update_driver_config") +def test_open_lab_success(mock_update_cfg, mock_run, mock_exists): + mock_exists.return_value = True + mock_update_cfg.return_value = True + + # Simulate Docker Compose Up + mock_run.return_value = MagicMock(returncode=0) + + open_lab() + + # Should run 'docker compose up' AND 'docker exec' + assert mock_run.call_count == 2 + assert mock_run.call_args_list[0][0][0][1] == "compose" + assert mock_run.call_args_list[1][0][0][1] == "exec" + + +## 3. Test Safety Guard (Already in Lab) +def test_open_lab_aborts_if_already_inside(monkeypatch): + monkeypatch.setenv("IS_IN_LAB", "True") + + with patch("argo_beast.cli.beast_lab.warn") as mock_warn: + open_lab() + mock_warn.assert_called_with("You are already in the lab!") + + +## 4. Test Docker Permission Denied Handling +@patch("os.path.exists") +@patch("subprocess.run") +@patch("argo_beast.cli.beast_lab._update_driver_config") +def test_open_lab_permission_denied(mock_update_cfg, mock_run, mock_exists): + mock_exists.return_value = True + mock_update_cfg.return_value = True + + # Simulate the Permission Denied error from Docker + error = subprocess.CalledProcessError(1, cmd="docker compose") + error.stderr = "permission denied while trying to connect to the Docker daemon" + mock_run.side_effect = error + + with patch("argo_beast.cli.beast_lab.warn") as mock_warn: + open_lab() + # Verify the custom permission instructions were triggered + mock_warn.assert_any_call( + "[SYSTEM] Permission Denied: Cannot connect to the Docker daemon." + ) + + +## 5. Test Config Wiring (driver.yml) +@patch("os.path.exists") +@patch("builtins.open", new_callable=mock_open, read_data="browser: chrome\n") +def test_update_driver_config_appends_url(mock_file, mock_exists): + mock_exists.return_value = True + + result = _update_driver_config() + + assert result is True + # Verify it opened in append mode + mock_file.assert_called_with("config/driver.yml", "a", encoding="utf-8") + mock_file().write.assert_any_call("\n# Added by argobeast build lab\n") + mock_file().write.assert_any_call('remote_url: "http://selenium-grid:4444/wd/hub"\n') + + +## 6. Test Closing the Lab +@patch("os.path.exists") +@patch("subprocess.run") +def test_close_lab(mock_run, mock_exists): + # Ensure it doesn't think it's inside the lab + with patch.dict(os.environ, {}, clear=True): + close_lab() + # Check if 'docker compose down' was called + assert mock_run.call_args[0][0][4] == "down" diff --git a/uv.lock b/uv.lock index 7057edc..0672efe 100644 --- a/uv.lock +++ b/uv.lock @@ -56,7 +56,7 @@ wheels = [ [[package]] name = "argobeast" -version = "2.1.4" +version = "2.2.4b1" source = { editable = "." } dependencies = [ { name = "behave" },