diff --git a/plugins/tutor-contrib-paragon/Makefile b/plugins/tutor-contrib-paragon/Makefile index 1790cb0..4a5917d 100644 --- a/plugins/tutor-contrib-paragon/Makefile +++ b/plugins/tutor-contrib-paragon/Makefile @@ -22,12 +22,15 @@ isort: ## Sort imports. This target is not mandatory because the output may be isort --skip=templates ${SRC_DIRS} unittest: ## Run code tests cases - pytest tests + pytest tests --ignore=tests/integration + +integration-test: ## Run integration tests cases + pytest tests/integration --order-scope=module dev-requirements: ## Install dev requirements pip install -e .[dev] -run-tests: test unittest # Run static analysis and unit tests +run-tests: test unittest integration-test # Run all tests: static analysis, unit tests, and integration tests ESCAPE =  help: ## Print this help diff --git a/plugins/tutor-contrib-paragon/pyproject.toml b/plugins/tutor-contrib-paragon/pyproject.toml index 3be3736..8e7b708 100644 --- a/plugins/tutor-contrib-paragon/pyproject.toml +++ b/plugins/tutor-contrib-paragon/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "tutor>=19.0.0,<20.0.0", ] -optional-dependencies = { dev = ["tutor[dev]>=19.0.0,<20.0.0", "pytest>=8.3.4"] } +optional-dependencies = { dev = ["tutor[dev]>=19.0.0,<20.0.0", "pytest>=8.3.4", "pytest-order>=1.3.0"] } # These fields will be set by hatch_build.py dynamic = ["version"] diff --git a/plugins/tutor-contrib-paragon/tests/integration/__init__.py b/plugins/tutor-contrib-paragon/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/tutor-contrib-paragon/tests/integration/conftest.py b/plugins/tutor-contrib-paragon/tests/integration/conftest.py new file mode 100644 index 0000000..1b0adce --- /dev/null +++ b/plugins/tutor-contrib-paragon/tests/integration/conftest.py @@ -0,0 +1,35 @@ +"""Common fixtures for integration tests.""" + +import pytest +import subprocess + +from .helpers import PARAGON_NAME, PARAGON_IMAGE + + +@pytest.fixture(scope="package", autouse=True) +def setup_tutor_paragon_plugin(): + """ + Fixture to set up the Tutor Paragon plugin for integration tests. + This fixture enables the Paragon plugin, builds the necessary Docker image, + and ensures that the plugin is disabled after the tests are complete. + """ + + subprocess.run( + ["tutor", "plugins", "enable", PARAGON_NAME], + check=True, + capture_output=True, + ) + + subprocess.run( + ["tutor", "images", "build", PARAGON_IMAGE], + check=True, + capture_output=True, + ) + + yield + + subprocess.run( + ["tutor", "plugins", "disable", PARAGON_NAME], + check=True, + capture_output=True, + ) diff --git a/plugins/tutor-contrib-paragon/tests/integration/helpers.py b/plugins/tutor-contrib-paragon/tests/integration/helpers.py new file mode 100644 index 0000000..8cbc763 --- /dev/null +++ b/plugins/tutor-contrib-paragon/tests/integration/helpers.py @@ -0,0 +1,54 @@ +"""Helper functions for integration tests of Paragon plugin.""" + +import subprocess +import logging + +logger = logging.getLogger(__name__) + +PARAGON_NAME = "paragon" +PARAGON_IMAGE = "paragon-builder" +PARAGON_JOB = "paragon-build-tokens" +PARAGON_THEME_SOURCES_FOLDER = "env/plugins/paragon/theme-sources" +PARAGON_COMPILED_THEMES_FOLDER = "env/plugins/paragon/compiled-themes" + + +def execute_tutor_command(command: list[str]): + """Run a Tutor command and return the result. + + Args: + command (list[str]): List of Tutor args, without the 'tutor' prefix. + + Returns: + subprocess.CompletedProcess: Contains stdout, stderr, returncode. + """ + full_command = ["tutor"] + command + result = subprocess.run( + full_command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + + if result.returncode != 0: + logger.error("Command failed: %s", " ".join(full_command)) + logger.error("stderr: %s", result.stderr.strip()) + + return result + + +def get_tutor_root_path(): + """Get the root path of the Tutor project. + + Raises: + RuntimeError: If the Tutor root path cannot be obtained. + + Returns: + str: The path to the Tutor root directory. + """ + result = execute_tutor_command(["config", "printroot"]) + + if result.returncode != 0: + raise RuntimeError("Failed to get Tutor root path: " + result.stderr) + + return result.stdout.strip() diff --git a/plugins/tutor-contrib-paragon/tests/integration/plugin_functionality_test.py b/plugins/tutor-contrib-paragon/tests/integration/plugin_functionality_test.py new file mode 100644 index 0000000..16d2369 --- /dev/null +++ b/plugins/tutor-contrib-paragon/tests/integration/plugin_functionality_test.py @@ -0,0 +1,147 @@ +""" +Integration tests for the Tutor Paragon plugin functionality. + +This module contains tests to verify that the Paragon plugin for Tutor +is functioning correctly, including building tokens with and without options, +and handling invalid flags or parameters. +""" + +import os +import shutil +import pytest +import re + +from .helpers import ( + execute_tutor_command, + get_tutor_root_path, + PARAGON_JOB, + PARAGON_COMPILED_THEMES_FOLDER, +) + + +@pytest.fixture(autouse=True) +def clear_compiled_themes_folder(): + """ + Fixture to clear the PARAGON_COMPILED_THEMES_FOLDER after each test. + """ + yield + tutor_root = get_tutor_root_path() + compiled_path = os.path.join(tutor_root, PARAGON_COMPILED_THEMES_FOLDER) + if os.path.exists(compiled_path): + shutil.rmtree(compiled_path) + + +@pytest.mark.order(1) +def test_build_tokens_without_options(): + """ + Verify that running the build-tokens job without additional options + completes successfully and produces output in the compiled-themes folder. + """ + + result = execute_tutor_command(["local", "do", PARAGON_JOB]) + assert result.returncode == 0, f"Error running build-tokens job: {result.stderr}" + + tutor_root = get_tutor_root_path() + compiled_path = os.path.join(tutor_root, PARAGON_COMPILED_THEMES_FOLDER) + + contents = os.listdir(compiled_path) + assert contents, f"No files were generated in {compiled_path}." + + +@pytest.mark.order(2) +def test_build_tokens_with_specific_theme(): + """ + Verify that running the build-tokens job with the --themes option + for a specific theme (e.g., 'indigo') produces the expected output. + """ + theme = "indigo" + + result = execute_tutor_command(["local", "do", PARAGON_JOB, "--themes", theme]) + assert result.returncode == 0, f"Error building {theme} theme: {result.stderr}" + + tutor_root = get_tutor_root_path() + compiled_path = os.path.join(tutor_root, PARAGON_COMPILED_THEMES_FOLDER, "themes") + + entries = os.listdir(compiled_path) + assert theme in entries, f"'{theme}' theme not found in {compiled_path}." + + theme_path = os.path.join(compiled_path, theme) + assert os.path.isdir(theme_path), f"Expected {theme_path} to be a directory." + assert os.listdir(theme_path), f"No files were generated inside {theme_path}." + + +@pytest.mark.order(3) +def test_build_tokens_excluding_core(): + """ + Verify that running the build-tokens job with the --exclude-core option + excludes the core theme from the output. + """ + result = execute_tutor_command(["local", "do", PARAGON_JOB, "--exclude-core"]) + assert result.returncode == 0, f"Error excluding core theme: {result.stderr}" + + tutor_root = get_tutor_root_path() + compiled_path = os.path.join(tutor_root, PARAGON_COMPILED_THEMES_FOLDER) + + entries = os.listdir(compiled_path) + assert "core" not in entries, "Core theme should be excluded but was found." + + +@pytest.mark.order(4) +def test_build_tokens_without_output_token_references(): + """ + Ensure that when the build-tokens job is run with --output-references=false, + the generated variables.css file does not contain any CSS variable references (var(--...)). + """ + result = execute_tutor_command( + ["local", "do", PARAGON_JOB, "--output-references=false"] + ) + assert ( + result.returncode == 0 + ), f"Error running build-tokens with --output-references=false: {result.stderr}" + + tutor_root = get_tutor_root_path() + compiled_path = os.path.join(tutor_root, PARAGON_COMPILED_THEMES_FOLDER) + + core_variables_css = os.path.join(compiled_path, "core", "variables.css") + theme_variables_css = os.path.join(compiled_path, "themes", "light", "variables.css") + + assert os.path.exists(core_variables_css), f"{core_variables_css} does not exist." + assert os.path.exists(theme_variables_css), f"{theme_variables_css} does not exist." + + with open(core_variables_css, "r", encoding="utf-8") as f: + core_content = f.read() + with open(theme_variables_css, "r", encoding="utf-8") as f: + theme_content = f.read() + + token_reference_pattern = re.compile(r"var\(--.*?\)") + core_references = token_reference_pattern.findall(core_content) + theme_references = token_reference_pattern.findall(theme_content) + + assert ( + not core_references + ), f"{core_variables_css} should not contain token references, but found: {core_references}" + assert ( + not theme_references + ), f"{theme_variables_css} should not contain token references, but found: {theme_references}" + + +@pytest.mark.order(5) +def test_build_tokens_with_source_tokens_only(): + """ + Ensure that when the build-tokens job is run with --source-tokens-only, + the utility-classes.css file is not generated. + """ + result = execute_tutor_command(["local", "do", PARAGON_JOB, "--source-tokens-only"]) + assert ( + result.returncode == 0 + ), f"Error running build-tokens with --source-tokens-only: {result.stderr}" + + tutor_root = get_tutor_root_path() + light_theme_path = os.path.join( + tutor_root, PARAGON_COMPILED_THEMES_FOLDER, "themes", "light" + ) + utility_classes_css = os.path.join(light_theme_path, "utility-classes.css") + + assert not os.path.exists( + utility_classes_css + ), f"{utility_classes_css} should not exist when --source-tokens-only is used." diff --git a/plugins/tutor-contrib-paragon/tests/integration/plugin_setup_test.py b/plugins/tutor-contrib-paragon/tests/integration/plugin_setup_test.py new file mode 100644 index 0000000..b46fc24 --- /dev/null +++ b/plugins/tutor-contrib-paragon/tests/integration/plugin_setup_test.py @@ -0,0 +1,65 @@ +""" +Integration tests for the Tutor Paragon plugin setup. + +This module contains tests to verify that the Paragon plugin for Tutor +is correctly installed, enabled, and has the expected directory structure +and jobs available in the system. +""" + +from .helpers import ( + execute_tutor_command, + get_tutor_root_path, + PARAGON_NAME, + PARAGON_JOB, + PARAGON_COMPILED_THEMES_FOLDER, + PARAGON_THEME_SOURCES_FOLDER, +) + +import pytest +import logging +import os + +logger = logging.getLogger(__name__) + + +@pytest.mark.order(1) +def test_paragon_plugin_installed(): + """Verify that the 'paragon' plugin is installed and enabled.""" + + plugins_list_cmd = ["plugins", "list"] + result = execute_tutor_command(plugins_list_cmd) + + assert result.returncode == 0, f"Error listing plugins: {result.stderr}" + assert ( + PARAGON_NAME in result.stdout + ), f"The '{PARAGON_NAME}' plugin is not installed" + assert "enabled" in result.stdout, f"The '{PARAGON_NAME}' plugin is not enabled" + + +@pytest.mark.order(2) +def test_paragon_plugin_folders_created(): + """Verify that the 'paragon' plugin's folders exist in the filesystem.""" + + project_root = get_tutor_root_path() + + folders_to_check = [ + PARAGON_THEME_SOURCES_FOLDER, + PARAGON_COMPILED_THEMES_FOLDER, + ] + + for folder in folders_to_check: + folder_path = f"{project_root}/{folder}" + + assert os.path.exists(folder_path), f"Folder {folder_path} does not exist." + assert os.path.isdir(folder_path), f"{folder_path} is not a directory." + + +@pytest.mark.order(3) +def test_paragon_plugin_build_tokens_job_exists(): + """Verify that the 'paragon-build-tokens' job exists in Tutor's configuration.""" + + jobs_list_cmd = ["local", "do", "-h"] + result = execute_tutor_command(jobs_list_cmd) + + assert result.returncode == 0, f"Error listing jobs: {result.stderr}" + assert PARAGON_JOB in result.stdout, f"Job '{PARAGON_JOB}' does not exist" diff --git a/plugins/tutor-contrib-paragon/tutorparagon/plugin.py b/plugins/tutor-contrib-paragon/tutorparagon/plugin.py index e9a3f93..d7d53ec 100644 --- a/plugins/tutor-contrib-paragon/tutorparagon/plugin.py +++ b/plugins/tutor-contrib-paragon/tutorparagon/plugin.py @@ -5,10 +5,13 @@ import importlib_resources from tutor import hooks from tutor import config as tutor_config +import logging from .__about__ import __version__ from .commands import paragon_build_tokens +logger = logging.getLogger(__name__) + ######################################## # CONFIGURATION ######################################## @@ -53,10 +56,10 @@ def create_paragon_folders(project_root: str) -> None: (compiled_themes_path, "Compiled Themes"), ]: if os.path.exists(path): - print(f"[paragon] {label} folder already exists at: {path}") + logger.info(f"[paragon] {label} folder already exists at: {path}") else: os.makedirs(path, exist_ok=True) - print(f"[paragon] Created {label} folder at: {path}") + logger.info(f"[paragon] Created {label} folder at: {path}") ######################################## diff --git a/plugins/tutor-contrib-paragon/tutorparagon/templates/paragon/build/paragon-builder/entrypoint.sh b/plugins/tutor-contrib-paragon/tutorparagon/templates/paragon/build/paragon-builder/entrypoint.sh index 00de177..e62a895 100644 --- a/plugins/tutor-contrib-paragon/tutorparagon/templates/paragon/build/paragon-builder/entrypoint.sh +++ b/plugins/tutor-contrib-paragon/tutorparagon/templates/paragon/build/paragon-builder/entrypoint.sh @@ -53,6 +53,7 @@ npx paragon build-tokens \ # Moves the built themes to the final volume directory. mkdir -p "$FINAL_BUILD_DIR" cp -a "$TMP_BUILD_DIR/." "$FINAL_BUILD_DIR/" +chmod -R a+rw "$FINAL_BUILD_DIR" # Clean up rm -rf "$TMP_BUILD_DIR"