diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..c712d259 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = tests/* diff --git a/tests/conftest.py b/tests/conftest.py index 5c7aa6c4..9d87804f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,10 +5,14 @@ # EESSI software layer, see https://github.com/EESSI/software-layer # # author: Thomas Roeblitz (@trz42) +# author: Sondre Bergsvaag Risanger (@sondrebr) # # license: GPLv2 # +import os +import shutil + def pytest_configure(config): # register custom markers @@ -24,3 +28,18 @@ def pytest_configure(config): config.addinivalue_line( "markers", "create_fails(bool): let function create_issue_comment return None" ) + + +def pytest_sessionstart(): + # Back up app.cfg if it exists + if os.path.exists("app.cfg"): + shutil.copyfile("app.cfg", "appbackup.cfg") + + # Copy needed app.cfg from tests directory + shutil.copyfile("tests/test_app.cfg", "app.cfg") + + +def pytest_sessionfinish(): + # Restore backup if it exists + if os.path.exists("appbackup.cfg"): + shutil.copyfile("appbackup.cfg", "app.cfg") diff --git a/tests/test_app.cfg b/tests/test_app.cfg index 56c1d6cc..d0fff239 100644 --- a/tests/test_app.cfg +++ b/tests/test_app.cfg @@ -36,3 +36,4 @@ running_job = job `{job_id}` is running [finished_job_comments] [bot_control] +command_permission = user01 second_user diff --git a/tests/test_eessi_bot_job_manager.py b/tests/test_eessi_bot_job_manager.py index 5c5a9c05..27c33478 100644 --- a/tests/test_eessi_bot_job_manager.py +++ b/tests/test_eessi_bot_job_manager.py @@ -12,15 +12,10 @@ # license: GPLv2 # -import shutil - from eessi_bot_job_manager import EESSIBotSoftwareLayerJobManager def test_determine_running_jobs(): - # copy needed app.cfg from tests directory - shutil.copyfile("tests/test_app.cfg", "app.cfg") - job_manager = EESSIBotSoftwareLayerJobManager() assert job_manager.determine_running_jobs({}) == [] diff --git a/tests/test_task_build.py b/tests/test_task_build.py index fcb9f428..cab91a35 100644 --- a/tests/test_task_build.py +++ b/tests/test_task_build.py @@ -18,7 +18,6 @@ import filecmp import os import re -import shutil from unittest.mock import patch # Third party imports (anything installed into the local Python environment) @@ -37,25 +36,27 @@ from tests.test_tools_pr_comments import MockIssueComment -def test_run_cmd(tmpdir): +def test_run_cmd(tmp_path): """Tests for run_cmd function.""" - log_file = os.path.join(tmpdir, "log.txt") - output, err, exit_code = run_cmd("echo hello", 'test', tmpdir, log_file=log_file) + log_file = os.path.join(tmp_path, "log.txt") + output, err, exit_code = run_cmd("echo hello", 'test', tmp_path, log_file=log_file) assert exit_code == 0 assert output == "hello\n" assert err == "" + # Command fails and raise_on_error=True with pytest.raises(Exception): - output, err, exit_code = run_cmd("ls -l /does_not_exists.txt", 'fail test', tmpdir, log_file=log_file) + output, err, exit_code = run_cmd("ls -l /does_not_exists.txt", 'fail test', tmp_path, log_file=log_file) assert exit_code != 0 assert output == "" assert "No such file or directory" in err + # Command fails and raise_on_error=False output, err, exit_code = run_cmd("ls -l /does_not_exists.txt", 'fail test', - tmpdir, + tmp_path, log_file=log_file, raise_on_error=False) @@ -63,17 +64,19 @@ def test_run_cmd(tmpdir): assert output == "" assert "No such file or directory" in err + # Command does not exists and raise_on_error=True with pytest.raises(Exception): - output, err, exit_code = run_cmd("this_command_does_not_exist", 'fail test', tmpdir, log_file=log_file) + output, err, exit_code = run_cmd("this_command_does_not_exist", 'fail test', tmp_path, log_file=log_file) assert exit_code != 0 assert output == "" assert ("this_command_does_not_exist: command not found" in err or "this_command_does_not_exist: not found" in err) + # Command does not exists and raise_on_error=False output, err, exit_code = run_cmd("this_command_does_not_exist", 'fail test', - tmpdir, + tmp_path, log_file=log_file, raise_on_error=False) @@ -82,33 +85,69 @@ def test_run_cmd(tmpdir): assert ("this_command_does_not_exist: command not found" in err or "this_command_does_not_exist: not found" in err) - output, err, exit_code = run_cmd("echo hello", "test in file", tmpdir, log_file=log_file) + # Check that log_msg is written to log_file + output, err, exit_code = run_cmd("echo hello", "test in file", tmp_path, log_file=log_file) with open(log_file, "r") as fp: assert "test in file" in fp.read() -def test_run_subprocess(tmpdir): +def test_run_subprocess(tmp_path): """Tests for run_subprocess function.""" - log_file = os.path.join(tmpdir, "log.txt") - output, err, exit_code = run_subprocess("echo hello", 'test', tmpdir, log_file=log_file) + log_file = os.path.join(tmp_path, "log.txt") + output, err, exit_code = run_subprocess("echo hello", 'test', tmp_path, log_file=log_file) assert exit_code == 0 assert output == "hello\n" assert err == "" - output, err, exit_code = run_subprocess("ls -l /does_not_exists.txt", 'fail test', tmpdir, log_file=log_file) + # log_msg="" + output, err, exit_code = run_subprocess("echo hello", "", tmp_path, log_file=log_file) + + assert exit_code == 0 + assert output == "hello\n" + assert err == "" + with open(log_file, "r") as fp: + # TODO: Better way to do this? + assert "run_subprocess(): Running" in fp.read() + + # working_dir=tmp_path + output, err, exit_code = run_subprocess("pwd", "test", tmp_path, log_file=log_file) + + assert exit_code == 0 + assert f"{tmp_path}\n" == output + assert err == "" + + # working_dir=None + wd = os.getcwd() + output, err, exit_code = run_subprocess("pwd", "test", None, log_file=log_file) + + assert exit_code == 0 + assert wd in output + assert err == "" + + # env is not None + output, err, exit_code = run_subprocess("env", "test", tmp_path, log_file=log_file, env={"DUMMY": "123"}) + + assert exit_code == 0 + assert "DUMMY=123" in output + assert err == "" + + # Command fails + output, err, exit_code = run_subprocess("ls -l /does_not_exists.txt", 'fail test', tmp_path, log_file=log_file) assert exit_code != 0 assert output == "" assert "No such file or directory" in err - output, err, exit_code = run_subprocess("this_command_does_not_exist", 'fail test', tmpdir, log_file=log_file) + # Command does not exist + output, err, exit_code = run_subprocess("this_command_does_not_exist", 'fail test', tmp_path, log_file=log_file) assert exit_code != 0 assert output == "" assert ("this_command_does_not_exist: command not found" in err or "this_command_does_not_exist: not found" in err) - output, err, exit_code = run_subprocess("echo hello", "test in file", tmpdir, log_file=log_file) + # Check that log_msg is written to log_file + output, err, exit_code = run_subprocess("echo hello", "test in file", tmp_path, log_file=log_file) with open(log_file, "r") as fp: assert "test in file" in fp.read() @@ -279,15 +318,14 @@ def no_sleep_after_create(delay): # returns !None --> create_pr_comment returns comment (with id == 1) @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) -def test_create_pr_comment_succeeds(monkeypatch, mocked_github, tmpdir): +def test_create_pr_comment_succeeds(monkeypatch, mocked_github, tmp_path): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) - shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') pr_number = 1 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") build_params = EESSIBotBuildParams("arch=amd/zen4,accel=nvidia/cc90") job_id = "123" @@ -310,15 +348,14 @@ def test_create_pr_comment_succeeds(monkeypatch, mocked_github, tmpdir): @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_fails(True) -def test_create_pr_comment_succeeds_none(monkeypatch, mocked_github, tmpdir): +def test_create_pr_comment_succeeds_none(monkeypatch, mocked_github, tmp_path): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) - shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') pr_number = 1 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") build_params = EESSIBotBuildParams("arch=amd/zen4,accel=nvidia/cc90") job_id = "123" @@ -337,15 +374,14 @@ def test_create_pr_comment_succeeds_none(monkeypatch, mocked_github, tmpdir): @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_raises("1") -def test_create_pr_comment_raises_once_then_succeeds(monkeypatch, mocked_github, tmpdir): +def test_create_pr_comment_raises_once_then_succeeds(monkeypatch, mocked_github, tmp_path): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) - shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') pr_number = 1 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") build_params = EESSIBotBuildParams("arch=amd/zen4,accel=nvidia/cc90") job_id = "123" @@ -364,15 +400,14 @@ def test_create_pr_comment_raises_once_then_succeeds(monkeypatch, mocked_github, @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_raises("always_raise") -def test_create_pr_comment_always_raises(monkeypatch, mocked_github, tmpdir): +def test_create_pr_comment_always_raises(monkeypatch, mocked_github, tmp_path): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) - shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') pr_number = 1 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") build_params = EESSIBotBuildParams("arch=amd/zen4,accel=nvidia/cc90") job_id = "123" @@ -392,15 +427,14 @@ def test_create_pr_comment_always_raises(monkeypatch, mocked_github, tmpdir): @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_raises("3") -def test_create_pr_comment_three_raises(monkeypatch, mocked_github, tmpdir): +def test_create_pr_comment_three_raises(monkeypatch, mocked_github, tmp_path): """Tests for function create_pr_comment.""" monkeypatch.setattr('tools.pr_comments.github', mocked_github) - shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") ym = datetime.today().strftime('%Y.%m') pr_number = 1 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed-up", ym, pr_number, "fpga/magic", "user01") build_params = EESSIBotBuildParams("arch=amd/zen4,accel=nvidia/cc90") job_id = "123" @@ -418,12 +452,12 @@ def test_create_pr_comment_three_raises(monkeypatch, mocked_github, tmpdir): @pytest.mark.repo_name("test_repo") @pytest.mark.pr_number(999) -def test_create_read_metadata_file(mocked_github, tmpdir): +def test_create_read_metadata_file(mocked_github, tmp_path): """Tests for function create_metadata_file.""" # create some test data ym = datetime.today().strftime('%Y.%m') pr_number = 999 - job = Job(tmpdir, "test/architecture", "EESSI", "--speed_up_job", ym, pr_number, "fpga/magic", "user01") + job = Job(tmp_path, "test/architecture", "EESSI", "--speed_up_job", ym, pr_number, "fpga/magic", "user01") job_id = "123" @@ -432,7 +466,7 @@ def test_create_read_metadata_file(mocked_github, tmpdir): create_metadata_file(job, job_id, pr_comment) expected_file = f"_bot_job{job_id}.metadata" - expected_file_path = os.path.join(tmpdir, expected_file) + expected_file_path = os.path.join(tmp_path, expected_file) # assert expected_file exists assert os.path.exists(expected_file_path) @@ -455,7 +489,7 @@ def test_create_read_metadata_file(mocked_github, tmpdir): assert sorted(metadata["PR"].keys()) == ["job_owner", "pr_comment_id", "pr_number", "repo"] # use directory that does not exist - dir_does_not_exist = os.path.join(tmpdir, "dir_does_not_exist") + dir_does_not_exist = os.path.join(tmp_path, "dir_does_not_exist") job2 = Job(dir_does_not_exist, "test/architecture", "EESSI", "--speed_up_job", ym, pr_number, "fpga/magic", "user01") job_id2 = "222" @@ -475,12 +509,12 @@ def test_create_read_metadata_file(mocked_github, tmpdir): # use undefined values for parameters # job_id = None - job4 = Job(tmpdir, "test/architecture", "EESSI", "--speed_up_job", ym, pr_number, "fpga/magic", "user01") + job4 = Job(tmp_path, "test/architecture", "EESSI", "--speed_up_job", ym, pr_number, "fpga/magic", "user01") job_id4 = None create_metadata_file(job4, job_id4, pr_comment) expected_file4 = f"_bot_job{job_id}.metadata" - expected_file_path4 = os.path.join(tmpdir, expected_file4) + expected_file_path4 = os.path.join(tmp_path, expected_file4) # assert expected_file exists assert os.path.exists(expected_file_path4) diff --git a/tests/test_tools_args.py b/tests/test_tools_args.py new file mode 100644 index 00000000..a0db511c --- /dev/null +++ b/tests/test_tools_args.py @@ -0,0 +1,85 @@ +# Tests for 'tools/args.py' of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +from argparse import Namespace +from contextlib import nullcontext + +# Third party imports (anything installed into the local Python environment) +import pytest + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import args + + +# Test parse_common_args() +@pytest.mark.parametrize("test_args,expected_parsed,expected_unknown", [ + # No args + ([], Namespace(debug=False), []), + + # Short-form args + (["-d"], Namespace(debug=True), []), + + # Long-form args + (["--debug"], Namespace(debug=True), []), + + # Unknown args + (["-u", "--unknown"], Namespace(debug=False), ["-u", "--unknown"]), +]) +def test_parse_common_args(test_args, expected_parsed, expected_unknown): + parsed_args, unknown = args.parse_common_args(test_args) + + assert parsed_args == expected_parsed + assert sorted(unknown) == sorted(expected_unknown) + + +# Test event_handler_parse() +@pytest.mark.parametrize("test_args,expectation", [ + # No args + ([], nullcontext(Namespace(debug=False, build=False, test=False, cron=False, file=None, port=3000))), + + # Short-form args + (["-d", "-b", "-t", "-c", "-f", "file.json", "-p", "8000"], + nullcontext(Namespace(debug=True, build=True, test=True, cron=True, file="file.json", port="8000"))), + + # Long-form args + (["--debug", "--build", "--test", "--cron", "--file", "file2.json", "--port", "9000"], + nullcontext(Namespace(debug=True, build=True, test=True, cron=True, file="file2.json", port="9000"))), + + # Unknown args - should fail and exit + (["-u"], pytest.raises(SystemExit)), + (["--unknown"], pytest.raises(SystemExit)), +]) +def test_event_handler_parse_known_args(test_args, expectation): + with expectation as expected: + assert args.event_handler_parse(test_args) == expected + + +# Test job_manager_parse() +@pytest.mark.parametrize("test_args,expectation", [ + # No args + ([], nullcontext(Namespace(debug=False, max_manager_iterations=-1, jobs=None))), + + # Short-form args + (["-d", "-i", "0", "-j", "17"], + nullcontext(Namespace(debug=True, max_manager_iterations="0", jobs="17"))), + + # Long-form args + (["--debug", "--max-manager-iterations", "10", "--jobs", "4,18,48"], + nullcontext(Namespace(debug=True, max_manager_iterations="10", jobs="4,18,48"))), + + # Unknown args - should fail and exit + (["-u"], pytest.raises(SystemExit)), + (["--unknown"], pytest.raises(SystemExit)), +]) +def test_job_manager_parse_known_args(test_args, expectation): + with expectation as expected: + assert args.job_manager_parse(test_args) == expected diff --git a/tests/test_tools_build_params.py b/tests/test_tools_build_params.py new file mode 100644 index 00000000..3c4e9441 --- /dev/null +++ b/tests/test_tools_build_params.py @@ -0,0 +1,43 @@ +# Tests for 'tools/build_params.py' of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +from contextlib import nullcontext + +# Third party imports (anything installed into the local Python environment) +import pytest + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import build_params + + +# Test EESSIBotBuildParams class +@pytest.mark.parametrize("build_parameters,expectation", [ + # Test value error + ("notaparam", pytest.raises(build_params.EESSIBotBuildParamsValueError)), + + # Test name error + ("thisparam=doesnotexist", pytest.raises(build_params.EESSIBotBuildParamsNameError)), + + # Test complete component names + ("architecture=x86_64/amd/zen4,accelerator=nvidia/cc80", + nullcontext({"architecture": "x86_64/amd/zen4", "accelerator": "nvidia/cc80"})), + + # Test shortened component names + ("arch=aarch64/nvidia/grace,accel=nvidia/cc90", + nullcontext({"architecture": "aarch64/nvidia/grace", "accelerator": "nvidia/cc90"})), +]) +def test_EESSIBotBuildParams(build_parameters, expectation): + with expectation as expected_params: + params = build_params.EESSIBotBuildParams(build_parameters) + # Verify that the resulting object contains the expected items + for name, expected_value in expected_params.items(): + assert params[name] == expected_value diff --git a/tests/test_tools_commands.py b/tests/test_tools_commands.py new file mode 100644 index 00000000..da0d70af --- /dev/null +++ b/tests/test_tools_commands.py @@ -0,0 +1,156 @@ +# Tests for 'tools/commands.py' of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +from contextlib import nullcontext + +# Third party imports (anything installed into the local Python environment) +import pytest + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import commands + + +# Test contains_any_bot_command() with both single-line and multi-line comments +@pytest.mark.parametrize("body,expected", [ + # Test single-line comments + # Existing command, no space after ':' + ("bot:help", True), + # Existing command, space after ':' + ("bot: help", True), + + # Command does not exist, no space after ':' + ("bot:nohelp", True), + # Command does not exist, space after ':' + ("bot: nohelp", True), + + # Leading whitespace, no space after ':' + (" bot:help", False), + # Leading whitespace, space after ':' + (" bot: help", False), + + # Test multi-line comments + # Valid command in first line, no space after ':' + ("\n".join(["bot:help", "not a command", "also not a command"]), True), + # Valid command in first line, space after ':' + ("\n".join(["bot: help", "not a command", "also not a command"]), True), + + # Valid command after first line, no space after ':' + ("\n".join(["not a command", "bot:help", "also not a command"]), True), + # Valid command after first line, space after ':' + ("\n".join(["not a command", "bot: help", "also not a command"]), True), + + # Multiple valid commands, no space after ':' + ("\n".join(["bot:help", "bot:nohelp", "also not a command"]), True), + # Multiple valid commands, space after ':' + ("\n".join(["bot: help", "bot: nohelp", "also not a command"]), True), + + # No commands + ("\n".join(["not a command", "also not a command"]), False), + + # Command with leading whitespace after second line, no space after ':' + ("\n".join(["not a command", " bot:help", "also not a command"]), False), + # Command with leading whitespace after second line, space after ':' + ("\n".join(["not a command", " bot: help", "also not a command"]), False), +]) +def test_contains_any_bot_command(body, expected): + assert commands.contains_any_bot_command(body) is expected + + +def test_get_bot_command(): + # Test non-command + no_cmd = commands.get_bot_command("not a command") + assert no_cmd is None + + # Test different commands with varying formatting + test_cmds = [ + # All existing commands + "build", "cancel", "help", "show_config", "status", + # Build command with filters + "build on:arch=icelake for:arch=x86_64/intel/icelake,accel=nvidia/cc90 repo:eessi.io-2025.06-software", + # Non-existant command + "this_command_does_not_exist", + ] + for test_cmd in test_cmds: + # Valid formatting, with and without space after ':' + cmd = commands.get_bot_command(f"bot:{test_cmd}") + assert cmd == test_cmd + cmd = commands.get_bot_command(f"bot: {test_cmd}") + assert cmd == test_cmd + + # Leading whitespace, with and without space after ':' - should return None + cmd = commands.get_bot_command(f" bot:{test_cmd}") + assert cmd is None + cmd = commands.get_bot_command(f" bot: {test_cmd}") + assert cmd is None + + # Without ':' - should return None + cmd = commands.get_bot_command(f"bot {test_cmd}") + assert cmd is None + + +# Helper classes for EESSIBotCommand test +class MockActionFilter(): + def __init__(self, action_filters=None): + self.action_filters = action_filters or [] + + def __eq__(self, other): + return self.action_filters == other.action_filters + + def to_string(self): + return " ".join([":".join(af) for af in self.action_filters]) + + +class MockCommand(): + def __init__(self, command, general_args=None, action_filters=None, build_params=None): + self.command = command + self.general_args = general_args or [] + self.action_filters = action_filters + self.build_params = build_params + + def __eq__(self, other): + return (self.command == other.command + and self.general_args == other.general_args + and self.action_filters == other.action_filters + and self.build_params == other.build_params) + + def to_string(self): + if self.action_filters is None: + return "" + string = self.command + if self.action_filters != MockActionFilter(): + string += f" {self.action_filters.to_string()}" + return string + + +# Test EESSIBotCommand class +@pytest.mark.parametrize("cmd_str,expectation", [ + # Test invalid filter + ("build for:arch=", pytest.raises(commands.EESSIBotCommandError)), + + # Test 'help' command + ("help", nullcontext(MockCommand("help", action_filters=MockActionFilter()))), + + # Test 'status' command with last_build arg + ("status last_build", nullcontext(MockCommand("status", general_args=["last_build"]))), + + # Test 'build' command + ("build on:arch=icelake for:arch=x86_64/intel/icelake,accel=nvidia/cc90 repo:eessi.io-2025.06-software", + nullcontext(MockCommand("build", + action_filters=MockActionFilter([("architecture", "icelake"), + ("repository", "eessi.io-2025.06-software")]), + build_params={"architecture": "x86_64/intel/icelake", "accelerator": "nvidia/cc90"}))) +]) +def test_EESSIBotCommand(cmd_str, expectation): + with expectation as expected_command: + command = commands.EESSIBotCommand(cmd_str) + assert command == expected_command + assert command.to_string() == expected_command.to_string() diff --git a/tests/test_tools_job_metadata.py b/tests/test_tools_job_metadata.py index 0d788248..5a1bdfc0 100644 --- a/tests/test_tools_job_metadata.py +++ b/tests/test_tools_job_metadata.py @@ -9,25 +9,58 @@ # license: GPLv2 # +# Standard library imports import os -from tools.job_metadata import get_section_from_file, JOB_PR_SECTION +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools.job_metadata import determine_job_id_from_job_directory, get_section_from_file, JOB_PR_SECTION -def test_get_section_from_file(tmpdir): - logfile = os.path.join(tmpdir, 'test_get_section_from_file.log') +def test_determine_job_id_from_job_directory(tmp_path): + logfile = os.path.join(tmp_path, "test_determine_job_id_from_job_directory.log") + + job_dir = os.path.join(tmp_path, "5") + assert determine_job_id_from_job_directory(job_dir, logfile) == 5 + + # determine_job_id_from_job_directory() should return 0 for non-job dirs + not_job_dir = os.path.join(tmp_path, "not-a-job-dir") + assert determine_job_id_from_job_directory(not_job_dir, logfile) == 0 + + +def test_get_section_from_file(tmp_path): + logfile = os.path.join(tmp_path, 'test_get_section_from_file.log') # if metadata file does not exist, we should get None as return value - path = os.path.join(tmpdir, 'test.metadata') + path = os.path.join(tmp_path, 'test.metadata') assert get_section_from_file(path, JOB_PR_SECTION, logfile) is None + # Reading an empty file should return an empty dictionary + with open(path, 'w') as _: + pass + metadata_pr = get_section_from_file(path, JOB_PR_SECTION, logfile) + assert metadata_pr == {} + + # Should return None if file exists but is invalid + with open(path, 'w') as fp: + fp.write("invalid format") + metadata_pr = get_section_from_file(path, JOB_PR_SECTION, logfile) + assert metadata_pr is None + + # Write a valid metadata file with open(path, 'w') as fp: - fp.write('''[PR] - repo=test - pr_number=12345''') + fp.write(""" +[PR] +repo=test +pr_number=12345 +pr_comment_id=23456 +job_owner=user01 +""") + # Verify that the metadata file is read correctly metadata_pr = get_section_from_file(path, JOB_PR_SECTION, logfile) expected = { "repo": "test", "pr_number": "12345", + "pr_comment_id": "23456", + "job_owner": "user01", } assert metadata_pr == expected diff --git a/tests/test_tools_logging.py b/tests/test_tools_logging.py new file mode 100644 index 00000000..a7855f4a --- /dev/null +++ b/tests/test_tools_logging.py @@ -0,0 +1,56 @@ +# Tests for 'tools/logging.py' of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +import os + +# Third party imports (anything installed into the local Python environment) +import pytest + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import logging + + +# Test error() - should write to stderr and exit +def test_error(capfd): + # Test exit code 1 (default) + msg = "this is the first error message" + with pytest.raises(SystemExit, check=lambda err: int(err.code) == 1): + logging.error(msg) + assert msg in capfd.readouterr().err + + # Test exit code 0 + msg2 = "this is the second error message" + with pytest.raises(SystemExit, check=lambda err: int(err.code) == 0): + logging.error(msg2, rc=0) + assert msg2 in capfd.readouterr().err + + +# Test log() +def test_log(monkeypatch, tmp_path): + # Use a new log file + log_file = os.path.join(tmp_path, "test.log") + monkeypatch.setattr(logging, "LOG", log_file) + + # log() should create the file if it does not exist + msg = "this is the first test message" + logging.log(msg) + assert os.path.exists(tmp_path) + with open(log_file, "r") as fp: + assert msg in fp.read() + + # log() should not truncate the file if it already exists + msg2 = "this is the second test message" + logging.log(msg2) + with open(log_file, "r") as fp: + log_contents = fp.read() + assert msg in log_contents + assert msg2 in log_contents diff --git a/tests/test_tools_permissions.py b/tests/test_tools_permissions.py new file mode 100644 index 00000000..c84f8eb9 --- /dev/null +++ b/tests/test_tools_permissions.py @@ -0,0 +1,33 @@ +# Tests for 'tools/permissions.py' of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Third party imports (anything installed into the local Python environment) +import pytest + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import permissions + + +# Test check_command_permission() +@pytest.mark.parametrize("user,expected", [ + # In test_app.cfg: + # command_permission = user01 second_user + + # Users in test config + ("user01", True), + ("second_user", True), + + # Users not in test config + ("user03", False), + ("another_user_not_in_cfg", False), +]) +def test_check_command_permission(user, expected): + assert permissions.check_command_permission(user) is expected diff --git a/tests/test_tools_pr_comments.py b/tests/test_tools_pr_comments.py index f89b3fd8..300fac25 100644 --- a/tests/test_tools_pr_comments.py +++ b/tests/test_tools_pr_comments.py @@ -636,8 +636,8 @@ def test_get_submitted_job_comment_retry(pr_job_get_comment_retry): # - pr.get_issue_comment(cmnt_id): 1st None ==> no edit # (patching pr.get_issue_comment via ContextManager to return None) -def test_update_comment_none(tmpdir): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_none(tmp_path): + log_file = os.path.join(tmp_path, "log.txt") with patch('github.PullRequest.PullRequest') as mock_pr: instance = mock_pr.return_value @@ -652,8 +652,8 @@ def test_update_comment_none(tmpdir): # log_file should contain error message "" expected = f"no comment with id {cmnt_id}, skipping update '{update}'" - file = tmpdir.join("log.txt") - actual = file.read() + file = tmp_path.joinpath("log.txt") + actual = file.read_text() # actual log message starts with a timestamp, hence we use 'in' assert expected in actual @@ -692,8 +692,8 @@ def test_update_comment_second_edit_succeeds(issue_edit_second_call_succeeds): # (edit_raises='N') or # (edit_raises='always_raise') # update_comment called with (str) -def test_update_comment_five_edit_fail(tmpdir, issue_edit_five_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_five_edit_fail(tmp_path, issue_edit_five_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") # issue_edit_five_calls_fail provides one comment with "foo" os.environ['TEST_RAISE_EXCEPTION'] = '0' with pytest.raises(IssueCommentEditException): @@ -713,8 +713,8 @@ def test_update_comment_five_edit_fail(tmpdir, issue_edit_five_calls_fail): assert expected == actual -def test_update_comment_all_edit_fail(tmpdir, issue_edit_all_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_all_edit_fail(tmp_path, issue_edit_all_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") # issue_edit_all_calls_fail provides one comment with "foo" os.environ['TEST_RAISE_EXCEPTION'] = '0' with pytest.raises(IssueCommentEditException): @@ -741,8 +741,8 @@ def test_update_comment_all_edit_fail(tmpdir, issue_edit_all_calls_fail): # (TEST_RAISE_EXCEPTION='0') # ==> edit: always fails (err2) # update_comment called with (int) -# def test_update_comment_edit_type_error(tmpdir, pr_with_any_comment): -# log_file = os.path.join(tmpdir, "log.txt") +# def test_update_comment_edit_type_error(tmp_path, pr_with_any_comment): +# log_file = os.path.join(tmp_path, "log.txt") # # pr_with_any_comment provides one comment with "foo" # os.environ['TEST_RAISE_EXCEPTION'] = '0' # #with pytest.raises(Exception) as err: @@ -763,8 +763,8 @@ def test_update_comment_all_edit_fail(tmpdir, issue_edit_all_calls_fail): # - pr.get_issue_comment(cmnt_id): 1st-Nth fail(err0) ==> no edit # (TEST_RAISE_EXCEPTION='N') -def test_update_comment_five_get_issue_comment_fail(tmpdir, issue_edit_five_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_five_get_issue_comment_fail(tmp_path, issue_edit_five_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") # issue_edit_five_calls_fail just provides retry testing for # get_issue_comment # since all calls to this shall fail, we don't use the edit part here @@ -788,8 +788,8 @@ def test_update_comment_five_get_issue_comment_fail(tmpdir, issue_edit_five_call # - pr.get_issue_comment(cmnt_id): 1st-Nth fail(err0) ==> no edit # (TEST_RAISE_EXCEPTION='always_raise') -def test_update_comment_all_get_issue_comment_fail(tmpdir, issue_edit_all_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_all_get_issue_comment_fail(tmp_path, issue_edit_all_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") # issue_edit_all_calls_fail just provides retry testing for # get_issue_comment # since all calls to this shall fail, we don't use the edit part here @@ -816,8 +816,8 @@ def test_update_comment_all_get_issue_comment_fail(tmpdir, issue_edit_all_calls_ # ==> edit: 1st succeeds # (edit_raises='0') # update_comment called with (str) -def test_update_comment_second_get_call_first_edit(tmpdir, issue_edit_first_call_succeeds): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_second_get_call_first_edit(tmp_path, issue_edit_first_call_succeeds): + log_file = os.path.join(tmp_path, "log.txt") os.environ['TEST_RAISE_EXCEPTION'] = '1' update_comment(0, issue_edit_first_call_succeeds, "-update", log_file=log_file) @@ -845,8 +845,8 @@ def test_update_comment_second_get_call_first_edit(tmpdir, issue_edit_first_call # ==> edit: 1st-(N-1)th fail(err1), 2nd-Nth succeeds # (edit_raises='1') # update_comment called with (str) -def test_update_comment_second_get_call_second_edit(tmpdir, issue_edit_second_call_succeeds): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_second_get_call_second_edit(tmp_path, issue_edit_second_call_succeeds): + log_file = os.path.join(tmp_path, "log.txt") os.environ['TEST_RAISE_EXCEPTION'] = '1' update_comment(0, issue_edit_second_call_succeeds, "-update", log_file=log_file) @@ -874,8 +874,8 @@ def test_update_comment_second_get_call_second_edit(tmpdir, issue_edit_second_ca # ==> edit: 1st-Nth fail(err1) # (edit_raises='N') or # update_comment called with (str) -def test_update_comment_second_get_call_five_edits_fail(tmpdir, issue_edit_five_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_second_get_call_five_edits_fail(tmp_path, issue_edit_five_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") os.environ['TEST_RAISE_EXCEPTION'] = '1' with pytest.raises(IssueCommentEditException): update_comment(0, issue_edit_five_calls_fail, "-update", log_file=log_file) @@ -904,8 +904,8 @@ def test_update_comment_second_get_call_five_edits_fail(tmpdir, issue_edit_five_ # ==> edit: 1st-Nth fail(err1) # (edit_raises='always_raise') # update_comment called with (str) -def test_update_comment_second_get_call_all_edits_fail(tmpdir, issue_edit_all_calls_fail): - log_file = os.path.join(tmpdir, "log.txt") +def test_update_comment_second_get_call_all_edits_fail(tmp_path, issue_edit_all_calls_fail): + log_file = os.path.join(tmp_path, "log.txt") os.environ['TEST_RAISE_EXCEPTION'] = '1' with pytest.raises(IssueCommentEditException): update_comment(0, issue_edit_all_calls_fail, "-update", log_file=log_file)