This commit is contained in:
elwrcl committed 2026-06-06 13:25:15 +03:00
1 parent 04d4dc8b09
commit 818cab85dc
12511 files changed
+6156641

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
-r ../requirements.txt
aiohttp==3.13.*
filelock==3.12.4
fire==0.7.0
freezegun==1.5.1
mock==5.1.0
polars==0.19.3
pytest==7.4.2
pytest-asyncio==0.21.0
pytest-cov==3.0.0
+746
View File
@@ -0,0 +1,746 @@
import logging
import subprocess
from datetime import datetime
import pytest
from custom_logger import CustomLogger
@pytest.fixture
def tmp_log_file(tmp_path):
return tmp_path / "test_log.json"
@pytest.fixture
def custom_logger(tmp_log_file):
return CustomLogger(tmp_log_file)
def run_script_with_args(args):
import custom_logger
script_path = custom_logger.__file__
return subprocess.run(
["python3", str(script_path), *args], capture_output=True, text=True
)
# Test case for missing log file
@pytest.mark.parametrize(
"key, value", [("dut_attempt_counter", "1"), ("job_combined_status", "pass")]
)
def test_missing_log_file_argument(key, value):
result = run_script_with_args(["--update", "key", "value"])
assert result.returncode != 0
# Parametrize test case for valid update arguments
@pytest.mark.parametrize(
"key, value", [("dut_attempt_counter", "1"), ("job_combined_status", "pass")]
)
def test_update_argument_valid(custom_logger, tmp_log_file, key, value):
result = run_script_with_args([str(tmp_log_file), "--update", key, value])
assert result.returncode == 0
# Test case for passing only the key without a value
def test_update_argument_key_only(custom_logger, tmp_log_file):
key = "dut_attempt_counter"
result = run_script_with_args([str(tmp_log_file), "--update", key])
assert result.returncode != 0
# Test case for not passing any key-value pair
def test_update_argument_no_values(custom_logger, tmp_log_file):
result = run_script_with_args([str(tmp_log_file), "--update"])
assert result.returncode == 0
# Parametrize test case for valid arguments
@pytest.mark.parametrize(
"key, value", [("dut_attempt_counter", "1"), ("job_combined_status", "pass")]
)
def test_create_argument_valid(custom_logger, tmp_log_file, key, value):
result = run_script_with_args([str(tmp_log_file), "--create-dut-job", key, value])
assert result.returncode == 0
# Test case for passing only the key without a value
def test_create_argument_key_only(custom_logger, tmp_log_file):
key = "dut_attempt_counter"
result = run_script_with_args([str(tmp_log_file), "--create-dut-job", key])
assert result.returncode != 0
# Test case for not passing any key-value pair
def test_create_argument_no_values(custom_logger, tmp_log_file):
result = run_script_with_args([str(tmp_log_file), "--create-dut-job"])
assert result.returncode == 0
# Test case for updating a DUT job
@pytest.mark.parametrize(
"key, value", [("status", "hung"), ("dut_state", "Canceling"), ("dut_name", "asus")]
)
def test_update_dut_job(custom_logger, tmp_log_file, key, value):
result = run_script_with_args([str(tmp_log_file), "--update-dut-job", key, value])
assert result.returncode != 0
result = run_script_with_args([str(tmp_log_file), "--create-dut-job", key, value])
assert result.returncode == 0
result = run_script_with_args([str(tmp_log_file), "--update-dut-job", key, value])
assert result.returncode == 0
# Test case for updating last DUT job
def test_update_dut_multiple_job(custom_logger, tmp_log_file):
# Create the first DUT job with the first key
result = run_script_with_args(
[str(tmp_log_file), "--create-dut-job", "status", "hung"]
)
assert result.returncode == 0
# Create the second DUT job with the second key
result = run_script_with_args(
[str(tmp_log_file), "--create-dut-job", "dut_state", "Canceling"]
)
assert result.returncode == 0
result = run_script_with_args(
[str(tmp_log_file), "--update-dut-job", "dut_name", "asus"]
)
assert result.returncode == 0
# Parametrize test case for valid phase arguments
@pytest.mark.parametrize(
"phase_name",
[("Phase1"), ("Phase2"), ("Phase3")],
)
def test_create_job_phase_valid(custom_logger, tmp_log_file, phase_name):
custom_logger.create_dut_job(status="pass")
result = run_script_with_args([str(tmp_log_file), "--create-job-phase", phase_name])
assert result.returncode == 0
# Test case for not passing any arguments for create-job-phase
def test_create_job_phase_no_arguments(custom_logger, tmp_log_file):
custom_logger.create_dut_job(status="pass")
result = run_script_with_args([str(tmp_log_file), "--create-job-phase"])
assert result.returncode != 0
# Test case for trying to create a phase job without an existing DUT job
def test_create_job_phase_no_dut_job(custom_logger, tmp_log_file):
phase_name = "Phase1"
result = run_script_with_args([str(tmp_log_file), "--create-job-phase", phase_name])
assert result.returncode != 0
# Combined test cases for valid scenarios
def test_valid_scenarios(custom_logger, tmp_log_file):
valid_update_args = [("dut_attempt_counter", "1"), ("job_combined_status", "pass")]
for key, value in valid_update_args:
result = run_script_with_args([str(tmp_log_file), "--update", key, value])
assert result.returncode == 0
valid_create_args = [
("status", "hung"),
("dut_state", "Canceling"),
("dut_name", "asus"),
("phase_name", "Bootloader"),
]
for key, value in valid_create_args:
result = run_script_with_args(
[str(tmp_log_file), "--create-dut-job", key, value]
)
assert result.returncode == 0
result = run_script_with_args(
[str(tmp_log_file), "--create-dut-job", "status", "hung"]
)
assert result.returncode == 0
result = run_script_with_args(
[str(tmp_log_file), "--update-dut-job", "dut_name", "asus"]
)
assert result.returncode == 0
result = run_script_with_args(
[
str(tmp_log_file),
"--create-job-phase",
"phase_name",
]
)
assert result.returncode == 0
# Parametrize test case for valid update arguments
@pytest.mark.parametrize(
"key, value", [("dut_attempt_counter", "1"), ("job_combined_status", "pass")]
)
def test_update(custom_logger, key, value):
custom_logger.update(**{key: value})
logger_data = custom_logger.logger.data
assert key in logger_data
assert logger_data[key] == value
# Test case for updating with a key that already exists
def test_update_existing_key(custom_logger):
key = "status"
value = "new_value"
custom_logger.logger.data[key] = "old_value"
custom_logger.update(**{key: value})
logger_data = custom_logger.logger.data
assert key in logger_data
assert logger_data[key] == value
# Test case for updating "dut_jobs"
def test_update_dut_jobs(custom_logger):
key1 = "status"
value1 = "fail"
key2 = "state"
value2 = "hung"
custom_logger.create_dut_job(**{key1: value1})
logger_data = custom_logger.logger.data
job1 = logger_data["dut_jobs"][0]
assert key1 in job1
assert job1[key1] == value1
custom_logger.update_dut_job(key2, value2)
logger_data = custom_logger.logger.data
job2 = logger_data["dut_jobs"][0]
assert key2 in job2
assert job2[key2] == value2
# Test case for creating and updating DUT job
def test_create_dut_job(custom_logger):
key = "status"
value1 = "pass"
value2 = "fail"
value3 = "hung"
reason = "job_combined_status"
result = "Finished"
custom_logger.update(**{reason: result})
logger_data = custom_logger.logger.data
assert reason in logger_data
assert logger_data[reason] == result
# Create the first DUT job
custom_logger.create_dut_job(**{key: value1})
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert isinstance(logger_data["dut_jobs"], list)
assert len(logger_data["dut_jobs"]) == 1
assert isinstance(logger_data["dut_jobs"][0], dict)
# Check the values of the keys in the created first DUT job
job1 = logger_data["dut_jobs"][0]
assert key in job1
assert job1[key] == value1
# Create the second DUT job
custom_logger.create_dut_job(**{key: value2})
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert isinstance(logger_data["dut_jobs"], list)
assert len(logger_data["dut_jobs"]) == 2
assert isinstance(logger_data["dut_jobs"][1], dict)
# Check the values of the keys in the created second DUT job
job2 = logger_data["dut_jobs"][1]
assert key in job2
assert job2[key] == value2
# Update the second DUT job with value3
custom_logger.update_dut_job(key, value3)
logger_data = custom_logger.logger.data
# Check the updated value in the second DUT job
job2 = logger_data["dut_jobs"][1]
assert key in job2
assert job2[key] == value3
# Find the index of the last DUT job
last_job_index = len(logger_data["dut_jobs"]) - 1
# Update the last DUT job
custom_logger.update_dut_job("dut_name", "asus")
logger_data = custom_logger.logger.data
# Check the updated value in the last DUT job
job2 = logger_data["dut_jobs"][last_job_index]
assert "dut_name" in job2
assert job2["dut_name"] == "asus"
# Check that "dut_name" is not present in other DUT jobs
for idx, job in enumerate(logger_data["dut_jobs"]):
if idx != last_job_index:
assert job.get("dut_name") == ""
# Test case for updating with missing "dut_jobs" key
def test_update_dut_job_missing_dut_jobs(custom_logger):
key = "status"
value = "fail"
# Attempt to update a DUT job when "dut_jobs" is missing
with pytest.raises(ValueError, match="No DUT jobs found."):
custom_logger.update_dut_job(key, value)
# Test case for creating a job phase
def test_create_job_phase(custom_logger):
custom_logger.create_dut_job(status="pass")
phase_name = "Phase1"
custom_logger.create_job_phase(phase_name)
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert isinstance(logger_data["dut_jobs"], list)
assert len(logger_data["dut_jobs"]) == 1
job = logger_data["dut_jobs"][0]
assert "dut_job_phases" in job
assert isinstance(job["dut_job_phases"], list)
assert len(job["dut_job_phases"]) == 1
phase = job["dut_job_phases"][0]
assert phase["name"] == phase_name
try:
datetime.fromisoformat(phase["start_time"])
assert True
except ValueError:
assert False
assert phase["end_time"] == ""
# Test case for creating multiple phase jobs
def test_create_multiple_phase_jobs(custom_logger):
custom_logger.create_dut_job(status="pass")
phase_data = [
{
"phase_name": "Phase1",
},
{
"phase_name": "Phase2",
},
{
"phase_name": "Phase3",
},
]
for data in phase_data:
phase_name = data["phase_name"]
custom_logger.create_job_phase(phase_name)
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert isinstance(logger_data["dut_jobs"], list)
assert len(logger_data["dut_jobs"]) == 1
job = logger_data["dut_jobs"][0]
assert "dut_job_phases" in job
assert isinstance(job["dut_job_phases"], list)
assert len(job["dut_job_phases"]) == len(phase_data)
for data in phase_data:
phase_name = data["phase_name"]
phase = job["dut_job_phases"][phase_data.index(data)]
assert phase["name"] == phase_name
try:
datetime.fromisoformat(phase["start_time"])
assert True
except ValueError:
assert False
if phase_data.index(data) != len(phase_data) - 1:
try:
datetime.fromisoformat(phase["end_time"])
assert True
except ValueError:
assert False
# Check if the end_time of the last phase is an empty string
last_phase = job["dut_job_phases"][-1]
assert last_phase["end_time"] == ""
# Test case for creating multiple dut jobs and updating phase job for last dut job
def test_create_two_dut_jobs_and_add_phase(custom_logger):
# Create the first DUT job
custom_logger.create_dut_job(status="pass")
# Create the second DUT job
custom_logger.create_dut_job(status="fail")
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert isinstance(logger_data["dut_jobs"], list)
assert len(logger_data["dut_jobs"]) == 2
first_dut_job = logger_data["dut_jobs"][0]
second_dut_job = logger_data["dut_jobs"][1]
# Add a phase to the second DUT job
custom_logger.create_job_phase("Phase1")
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert isinstance(logger_data["dut_jobs"], list)
assert len(logger_data["dut_jobs"]) == 2
first_dut_job = logger_data["dut_jobs"][0]
second_dut_job = logger_data["dut_jobs"][1]
# Check first DUT job does not have a phase
assert not first_dut_job.get("dut_job_phases")
# Check second DUT job has a phase
assert second_dut_job.get("dut_job_phases")
assert isinstance(second_dut_job["dut_job_phases"], list)
assert len(second_dut_job["dut_job_phases"]) == 1
# Test case for updating DUT start time
def test_update_dut_start_time(custom_logger):
custom_logger.create_dut_job(status="pass")
custom_logger.update_dut_time("start", None)
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert len(logger_data["dut_jobs"]) == 1
dut_job = logger_data["dut_jobs"][0]
assert "dut_start_time" in dut_job
assert dut_job["dut_start_time"] != ""
try:
datetime.fromisoformat(dut_job["dut_start_time"])
assert True
except ValueError:
assert False
# Test case for updating DUT submit time
def test_update_dut_submit_time(custom_logger):
custom_time = "2023-11-09T02:37:06Z"
custom_logger.create_dut_job(status="pass")
custom_logger.update_dut_time("submit", custom_time)
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert len(logger_data["dut_jobs"]) == 1
dut_job = logger_data["dut_jobs"][0]
assert "dut_submit_time" in dut_job
try:
datetime.fromisoformat(dut_job["dut_submit_time"])
assert True
except ValueError:
assert False
# Test case for updating DUT end time
def test_update_dut_end_time(custom_logger):
custom_logger.create_dut_job(status="pass")
custom_logger.update_dut_time("end", None)
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert len(logger_data["dut_jobs"]) == 1
dut_job = logger_data["dut_jobs"][0]
assert "dut_end_time" in dut_job
try:
datetime.fromisoformat(dut_job["dut_end_time"])
assert True
except ValueError:
assert False
# Test case for updating DUT time with invalid value
def test_update_dut_time_invalid_value(custom_logger):
custom_logger.create_dut_job(status="pass")
with pytest.raises(
ValueError,
match="Error: Invalid argument provided for --update-dut-time. Use 'start', 'submit', 'end'.",
):
custom_logger.update_dut_time("invalid_value", None)
# Test case for close_dut_job
def test_close_dut_job(custom_logger):
custom_logger.create_dut_job(status="pass")
custom_logger.create_job_phase("Phase1")
custom_logger.create_job_phase("Phase2")
custom_logger.close_dut_job()
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert len(logger_data["dut_jobs"]) == 1
dut_job = logger_data["dut_jobs"][0]
assert "dut_job_phases" in dut_job
dut_job_phases = dut_job["dut_job_phases"]
phase1 = dut_job_phases[0]
assert phase1["name"] == "Phase1"
try:
datetime.fromisoformat(phase1["start_time"])
assert True
except ValueError:
assert False
try:
datetime.fromisoformat(phase1["end_time"])
assert True
except ValueError:
assert False
phase2 = dut_job_phases[1]
assert phase2["name"] == "Phase2"
try:
datetime.fromisoformat(phase2["start_time"])
assert True
except ValueError:
assert False
try:
datetime.fromisoformat(phase2["end_time"])
assert True
except ValueError:
assert False
# Test case for close
def test_close(custom_logger):
custom_logger.create_dut_job(status="pass")
custom_logger.close()
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert len(logger_data["dut_jobs"]) == 1
assert "dut_attempt_counter" in logger_data
assert logger_data["dut_attempt_counter"] == len(logger_data["dut_jobs"])
assert "job_combined_status" in logger_data
assert logger_data["job_combined_status"] != ""
dut_job = logger_data["dut_jobs"][0]
assert "submitter_end_time" in dut_job
try:
datetime.fromisoformat(dut_job["submitter_end_time"])
assert True
except ValueError:
assert False
# Test case for updating status to fail with a reason
def test_update_status_fail_with_reason(custom_logger):
custom_logger.create_dut_job()
reason = "kernel panic"
custom_logger.update_status_fail(reason)
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert len(logger_data["dut_jobs"]) == 1
dut_job = logger_data["dut_jobs"][0]
assert "status" in dut_job
assert dut_job["status"] == "fail"
assert "dut_job_fail_reason" in dut_job
assert dut_job["dut_job_fail_reason"] == reason
# Test case for updating status to fail without providing a reason
def test_update_status_fail_without_reason(custom_logger):
custom_logger.create_dut_job()
custom_logger.update_status_fail()
# Check if the status is updated and fail reason is empty
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert len(logger_data["dut_jobs"]) == 1
dut_job = logger_data["dut_jobs"][0]
assert "status" in dut_job
assert dut_job["status"] == "fail"
assert "dut_job_fail_reason" in dut_job
assert dut_job["dut_job_fail_reason"] == ""
# Test case for check_dut_timings with submission time earlier than start time
def test_check_dut_timings_submission_earlier_than_start(custom_logger, caplog):
custom_logger.create_dut_job()
# Set submission time to be earlier than start time
custom_logger.update_dut_time("start", "2023-01-01T11:00:00")
custom_logger.update_dut_time("submit", "2023-01-01T12:00:00")
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert len(logger_data["dut_jobs"]) == 1
job = logger_data["dut_jobs"][0]
# Call check_dut_timings
custom_logger.check_dut_timings(job)
# Check if an error message is logged
assert "Job submission is happening before job start." in caplog.text
# Test case for check_dut_timings with end time earlier than start time
def test_check_dut_timings_end_earlier_than_start(custom_logger, caplog):
custom_logger.create_dut_job()
# Set end time to be earlier than start time
custom_logger.update_dut_time("end", "2023-01-01T11:00:00")
custom_logger.update_dut_time("start", "2023-01-01T12:00:00")
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert len(logger_data["dut_jobs"]) == 1
job = logger_data["dut_jobs"][0]
# Call check_dut_timings
custom_logger.check_dut_timings(job)
# Check if an error message is logged
assert "Job ended before it started." in caplog.text
# Test case for check_dut_timings with valid timing sequence
def test_check_dut_timings_valid_timing_sequence(custom_logger, caplog):
custom_logger.create_dut_job()
# Set valid timing sequence
custom_logger.update_dut_time("submit", "2023-01-01T12:00:00")
custom_logger.update_dut_time("start", "2023-01-01T12:30:00")
custom_logger.update_dut_time("end", "2023-01-01T13:00:00")
logger_data = custom_logger.logger.data
assert "dut_jobs" in logger_data
assert len(logger_data["dut_jobs"]) == 1
job = logger_data["dut_jobs"][0]
# Call check_dut_timings
custom_logger.check_dut_timings(job)
# Check that no error messages are logged
assert "Job submission is happening before job start." not in caplog.text
assert "Job ended before it started." not in caplog.text
@pytest.mark.parametrize("start_time,end_time,expected", [
("2023-01-01T12:00:00", "2023-01-01T12:30:00", "0:30:00"),
("invalid", "2023-01-01T12:30:00", ""),
("", "", ""),
])
def test_get_duration_time(custom_logger, start_time, end_time, expected):
duration = custom_logger.get_duration_time(start_time, end_time)
assert duration == expected
# Test case for DUT job duration tracking
def test_dut_job_duration(custom_logger):
custom_logger.create_dut_job(status="pass")
# Set start time
custom_logger.update_dut_time("start", "2023-01-01T12:00:00")
logger_data = custom_logger.logger.data
dut_job = logger_data["dut_jobs"][0]
assert dut_job["dut_duration_time"] == ""
# Set end time
custom_logger.update_dut_time("end", "2023-01-01T12:30:00")
logger_data = custom_logger.logger.data
dut_job = logger_data["dut_jobs"][0]
assert dut_job["dut_duration_time"] == "0:30:00"
# Test case for job phase duration tracking
def test_job_phase_duration(custom_logger):
custom_logger.create_dut_job(status="pass")
# Create first phase
custom_logger.create_job_phase("Phase1")
logger_data = custom_logger.logger.data
job = logger_data["dut_jobs"][0]
phase = job["dut_job_phases"][0]
assert phase["duration_time"] == ""
# Create second phase (which should close the first phase)
custom_logger.create_job_phase("Phase2")
logger_data = custom_logger.logger.data
job = logger_data["dut_jobs"][0]
# First phase should have duration
phase1 = job["dut_job_phases"][0]
assert phase1["duration_time"] != ""
# Verify duration format
start = datetime.fromisoformat(phase1["start_time"])
end = datetime.fromisoformat(phase1["end_time"])
duration = end - start
assert phase1["duration_time"] == str(duration)
# Second phase should have empty duration
phase2 = job["dut_job_phases"][1]
assert phase2["duration_time"] == ""
# Test case for closing job with phases
def test_close_job_with_phases_duration(custom_logger):
custom_logger.create_dut_job(status="pass")
custom_logger.create_job_phase("Phase1")
custom_logger.close_dut_job()
logger_data = custom_logger.logger.data
job = logger_data["dut_jobs"][0]
phase = job["dut_job_phases"][0]
assert phase["duration_time"] != ""
# Verify duration format
start = datetime.fromisoformat(phase["start_time"])
end = datetime.fromisoformat(phase["end_time"])
duration = end - start
assert phase["duration_time"] == str(duration)
+201
View File
@@ -0,0 +1,201 @@
from contextlib import suppress
from datetime import datetime, timedelta
from unittest import mock
from unittest.mock import MagicMock, patch
import ci_post_gantt
import pytest
from ci_gantt_chart import generate_gantt_chart
from ci_post_gantt import Gitlab, MockGanttExit
def create_mock_job(
name, id, status, created_at, queued_duration, started_at, finished_at=None
):
mock_job = MagicMock()
mock_job.name = name
mock_job.status = status
mock_job.id = id
mock_job.created_at = created_at
mock_job.queued_duration = queued_duration
mock_job.started_at = started_at
mock_job.finished_at = finished_at
return mock_job
@pytest.fixture
def fake_pipeline():
current_time = datetime.fromisoformat("2024-12-17 23:54:13.940091+00:00")
created_at = current_time - timedelta(minutes=10)
job1 = create_mock_job(
name="job1",
id="1",
status="success",
created_at=created_at.isoformat(),
queued_duration=1, # seconds
started_at=(created_at + timedelta(seconds=2)).isoformat(),
finished_at=(created_at + timedelta(minutes=1)).isoformat(),
)
mock_pipeline = MagicMock()
mock_pipeline.web_url = "https://gitlab.freedesktop.org/mesa/mesa/-/pipelines/9999"
mock_pipeline.duration = 600 # Total pipeline duration in seconds
mock_pipeline.created_at = created_at.isoformat()
mock_pipeline.yaml_errors = False
mock_pipeline.jobs.list.return_value = [job1]
return mock_pipeline
def test_generate_gantt_chart(fake_pipeline):
fig = generate_gantt_chart(fake_pipeline)
fig_dict = fig.to_dict()
assert "data" in fig_dict
# Extract all job names from the "y" axis in the Gantt chart data
all_job_names = set()
for trace in fig_dict["data"]:
if "y" in trace:
all_job_names.update(trace["y"])
assert any(
"job1" in job for job in all_job_names
), "job1 should be present in the Gantt chart"
def test_ci_timeout(fake_pipeline):
fig = generate_gantt_chart(fake_pipeline, ci_timeout=1)
fig_dict = fig.to_dict()
timeout_line = None
for shape in fig_dict.get("layout", {}).get("shapes", []):
if shape.get("line", {}).get("dash") == "dash":
timeout_line = shape
break
assert timeout_line is not None, "Timeout line should exist in the Gantt chart"
timeout_x = timeout_line["x0"]
# Check that the timeout line is 1 minute after the pipeline creation time
pipeline_created_at = datetime.fromisoformat(fake_pipeline.created_at)
expected_timeout = pipeline_created_at + timedelta(minutes=1)
assert (
timeout_x == expected_timeout
), f"Timeout should be at {expected_timeout}, got {timeout_x}"
def test_marge_bot_user_id():
with patch("ci_post_gantt.Gitlab") as MockGitlab:
mock_gitlab_instance = MagicMock(spec=Gitlab)
mock_gitlab_instance.users = MagicMock()
MockGitlab.return_value = mock_gitlab_instance
marge_bot_user_id = 12345
ci_post_gantt.main("fake_token", None, marge_bot_user_id)
mock_gitlab_instance.users.get.assert_called_once_with(marge_bot_user_id)
def test_project_ids():
current_time = datetime.now()
project_id_1 = 176
event_1 = MagicMock()
event_1.project_id = project_id_1
event_1.created_at = (current_time - timedelta(days=1)).isoformat()
event_1.note = {"body": f"Event for project {project_id_1}"}
project_id_2 = 166
event_2 = MagicMock()
event_2.project_id = project_id_2
event_2.created_at = (current_time - timedelta(days=2)).isoformat()
event_2.note = {"body": f"Event for project {project_id_2}"}
with patch("ci_post_gantt.Gitlab") as MockGitlab:
mock_user = MagicMock()
mock_user.events = MagicMock()
mock_user.events.list.return_value = [event_1, event_2]
mock_gitlab_instance = MagicMock(spec=Gitlab)
mock_gitlab_instance.users = MagicMock()
mock_gitlab_instance.users.get.return_value = mock_user
MockGitlab.return_value = mock_gitlab_instance
last_event_date = (current_time - timedelta(days=3)).isoformat()
# Test a single project id
ci_post_gantt.main("fake_token", last_event_date)
marge_bot_single_project_scope = [
event.note["body"]
for event in mock_user.events.list.return_value
if event.project_id == project_id_1
]
assert f"Event for project {project_id_1}" in marge_bot_single_project_scope
assert f"Event for project {project_id_2}" not in marge_bot_single_project_scope
# Test multiple project ids
ci_post_gantt.main(
"fake_token", last_event_date, 9716, [project_id_1, project_id_2]
)
marge_bot_multiple_project_scope = [
event.note["body"] for event in mock_user.events.list.return_value
]
assert f"Event for project {project_id_1}" in marge_bot_multiple_project_scope
assert f"Event for project {project_id_2}" in marge_bot_multiple_project_scope
def test_add_gantt_after_pipeline_message():
current_time = datetime.now()
plain_url = "https://gitlab.freedesktop.org/mesa/mesa/-/pipelines/12345"
plain_message = (
f"I couldn't merge this branch: CI failed! See pipeline {plain_url}."
)
event_plain = MagicMock()
event_plain.project_id = 176
event_plain.created_at = (current_time - timedelta(days=1)).isoformat()
event_plain.note = {"body": plain_message}
summary_url = "https://gitlab.freedesktop.org/mesa/mesa/-/pipelines/99999"
summary_message = (
"I couldn't merge this branch: "
f"CI failed! See pipeline {summary_url}.<br>There were problems with job:"
"[lavapipe](https://gitlab.freedesktop.org/mesa/mesa/-/jobs/68141218)<details><summary> "
"3 crashed tests</summary>dEQP-VK.ray_query.builtin.instancecustomindex.frag.aabbs,Crash<br>dEQP"
"-VK.ray_query.builtin.objecttoworld.frag.aabbs,Crash<br>dEQP-VK.sparse_resources.shader_intrinsics."
"2d_array_sparse_fetch.g16_b16r16_2plane_444_unorm.11_37_3_nontemporal,Crash<br></details>"
)
event_with_summary = MagicMock()
event_with_summary.project_id = 176
event_with_summary.created_at = (current_time - timedelta(days=1)).isoformat()
event_with_summary.note = {"body": summary_message}
with patch("ci_post_gantt.Gitlab") as MockGitlab, patch(
"ci_post_gantt.get_gitlab_pipeline_from_url", return_value=None
) as mock_get_gitlab_pipeline_from_url:
def safe_mock(*args, **kwargs):
with suppress(TypeError):
raise MockGanttExit("Exiting for test purposes")
mock_get_gitlab_pipeline_from_url.side_effect = safe_mock
mock_user = MagicMock()
mock_user.events = MagicMock()
mock_user.events.list.return_value = [event_plain, event_with_summary]
mock_gitlab_instance = MagicMock(spec=Gitlab)
mock_gitlab_instance.users = MagicMock()
mock_gitlab_instance.users.get.return_value = mock_user
MockGitlab.return_value = mock_gitlab_instance
last_event_date = (current_time - timedelta(days=3)).isoformat()
ci_post_gantt.main("fake_token", last_event_date, 12345)
mock_get_gitlab_pipeline_from_url.assert_has_calls(
[
mock.call(mock_gitlab_instance, plain_url),
mock.call(mock_gitlab_instance, summary_url),
],
any_order=True,
)
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
# Copyright © 2025 Collabora Ltd.
# Authors:
# Sergi Blanch Torne <[email protected]>
#
# SPDX-License-Identifier: MIT
from dataclasses import dataclass
from datetime import timedelta
from unittest import TestCase
from typing import Iterable
from marge_queue import MargeMergeRequest, MargeQueue, get_merge_queue
DATA = {
37564: {
# sample of a Merge Request rejected by marge and later reassigned to marge
"updated_at": "2025-09-25T10:59:27.144Z",
"title": "etnaviv: Fix util_blitter_save_so_targets(..) call",
"notes": [
# Those notes must be listed in reverse order
# because the list is requested sort="desc"
{
"body": "assigned to @marge-bot and unassigned @austriancoder",
"created_at": "2025-09-25T11:44:01.672Z",
},
{
"body": "added 1 commit",
"created_at": "2025-09-25T10:43:43.490Z",
},
{
"body": "This branch couldn't be merged:",
"created_at": "2025-09-25T10:42:27.817Z",
},
{
"body": "assigned to @austriancoder and unassigned @marge-bot",
"created_at": "2025-09-25T10:42:15.404Z",
},
{
"body": "added 3 commits",
"created_at": "2025-09-25T10:38:57.332Z",
},
{
"body": "assigned to @marge-bot",
"created_at": "2025-09-25T10:38:03.993Z",
},
],
},
37560: {
# sample of a Merge Request assigned to marge between the two
# assignments of the previous sample. This is meant to show that
# this goes first in the queue, and it doesn't confuses the sequence.
"updated_at": "2025-09-25T11:38:08.420Z",
"title": "tu: limit query pool types logged into RMV",
"notes": [
{
"body": "assigned to @marge-bot",
"created_at": "2025-09-25T10:47:40.040Z",
},
{
"body": "added 1 commit",
"created_at": "2025-09-25T10:46:42.113Z",
},
{
"body": "mentioned in issue #13970",
"created_at": "2025-09-25T10:45:35.566Z",
},
{
"body": "Rb",
"created_at": "2025-09-25T08:25:51.527Z",
},
],
},
}
@dataclass
class ProjectMergeRequestNote:
body: str
created_at: str
@dataclass
class ProjectMergeRequestNoteManager:
"""
Mock the gitlab.v4.objects.ProjectMergeRequestNoteManager
"""
iid: int
def list(self, *args, **kwargs) -> Iterable:
for note in DATA[self.iid]["notes"]:
yield ProjectMergeRequestNote(**note)
@dataclass
class ProjectMergeRequest:
"""
Mock the gitlab.v4.objects.ProjectMergeRequest
"""
iid: int
@property
def updated_at(self) -> str:
return DATA[self.iid]["updated_at"]
@property
def web_url(self) -> str:
return f"https://gitlab.freedesktop.org/mesa/mesa/-/merge_requests/{self.iid}"
@property
def title(self) -> str:
return DATA[self.iid]["title"]
@property
def notes(self) -> ProjectMergeRequestNoteManager:
return ProjectMergeRequestNoteManager(self.iid)
@dataclass
class ProjectMergeRequestManager:
"""
Mock the gitlab.v4.objects.ProjectMergeRequestManager
"""
def list(self, *args, **kwargs) -> Iterable:
for mr in DATA.keys():
yield ProjectMergeRequest(mr)
@dataclass
class Project:
"""
Mock the gitlab.v4.objects.Project
"""
@property
def mergerequests(self) -> ProjectMergeRequestManager:
return ProjectMergeRequestManager()
class MargeMergeTestCase(TestCase):
def test_queue(self) -> None:
queue = MargeQueue()
for mr_id in DATA.keys():
gl_mr_mock_obj = ProjectMergeRequest(mr_id)
marge_mr = MargeMergeRequest(gl_mr_mock_obj)
# for each element in the data sample, test the MargeMergeRequest construction
self.assertEqual(marge_mr.title, gl_mr_mock_obj.title)
self.assertEqual(marge_mr.web_url, gl_mr_mock_obj.web_url)
self.assertIsInstance(marge_mr.time_enqueued, timedelta)
queue.append(marge_mr)
self.assertEqual(queue.n_merge_requests_enqueued, 2)
def test_get_merge_queue(self) -> None:
project = Project()
queue = get_merge_queue(project)
self.assertEqual(queue.n_merge_requests_enqueued, 2)
self.assertEqual(len(queue.sorted_queue), 2)
# They are in reverse order because of the reassignment to marge
self.assertEqual(queue.sorted_queue[0].id, list(DATA.keys())[1])
self.assertEqual(queue.sorted_queue[1].id, list(DATA.keys())[0])
self.assertEqual(len(queue.undetermined), 0)
for marge_mr in queue.sorted_queue:
mr_id = marge_mr.id
self.assertEqual(marge_mr.title, DATA[mr_id]["title"])
self.assertEqual(
marge_mr.web_url,
f"https://gitlab.freedesktop.org/mesa/mesa/-/merge_requests/{mr_id}"
)
self.assertIsInstance(marge_mr.time_enqueued, timedelta)
+309
View File
@@ -0,0 +1,309 @@
import logging
from unittest.mock import AsyncMock, patch
import pytest
from pipeline_message import (
get_failed_test_summary_message,
get_problem_jobs,
get_trace_failures,
main,
process_problem_jobs,
search_job_log_for_errors,
sort_failed_tests_by_status,
unexpected_improvements,
)
def test_get_problem_jobs():
jobs = [
{"stage": "build", "status": "failed"},
{"stage": "test", "status": "canceled"},
{"stage": "postmerge", "status": "failed"},
{"stage": "performance", "status": "failed"},
{"stage": "deploy", "status": "failed"},
]
problem_jobs = get_problem_jobs(jobs)
assert len(problem_jobs) == 3
assert problem_jobs[0]["stage"] == "build"
assert problem_jobs[1]["stage"] == "test"
assert problem_jobs[2]["stage"] == "deploy"
def test_sort_failed_tests_by_status():
failures_csv = """\
Test1,UnexpectedImprovement
Test2,Fail
Test3,Crash
Test4,Timeout
Test5,Fail
Test6,UnexpectedImprovement
"""
sorted_tests = sort_failed_tests_by_status(failures_csv)
assert len(sorted_tests["unexpected_improvements"]) == 2
assert len(sorted_tests["fails"]) == 2
assert len(sorted_tests["crashes"]) == 1
assert len(sorted_tests["timeouts"]) == 1
assert sorted_tests["unexpected_improvements"] == [
"Test1,UnexpectedImprovement",
"Test6,UnexpectedImprovement",
]
assert sorted_tests["fails"] == ["Test2,Fail", "Test5,Fail"]
assert sorted_tests["crashes"] == ["Test3,Crash"]
assert sorted_tests["timeouts"] == ["Test4,Timeout"]
def test_get_failed_test_summary_message():
failed_test_array = {
"unexpected_improvements": [
"test1 UnexpectedImprovement",
"test2 UnexpectedImprovement",
],
"fails": ["test3 Fail", "test4 Fail", "test5 Fail"],
"crashes": ["test6 Crash"],
"timeouts": [],
}
summary_message = get_failed_test_summary_message(failed_test_array)
assert "<summary>" in summary_message
assert "2 improved tests" in summary_message
assert "3 failed tests" in summary_message
assert "1 crashed test" in summary_message
assert "</summary>" in summary_message
def test_unexpected_improvements():
message = "<summary>"
failed_test_array = {
"unexpected_improvements": ["test_improvement_1", "test_improvement_2"],
"fails": [],
"crashes": [],
"timeouts": [],
}
result = unexpected_improvements(failed_test_array)
assert result == " 2 improved tests", f"Unexpected result: {result}"
@pytest.mark.asyncio
@patch("pipeline_message.get_pipeline_status", new_callable=AsyncMock)
async def test_gitlab_api_failure(mock_get_pipeline_status):
mock_get_pipeline_status.side_effect = Exception("GitLab API not responding")
message = await main("1234567")
assert message == ""
@pytest.mark.asyncio
async def test_no_message_when_pipeline_not_failed():
project_id = "176"
pipeline_id = "12345"
with patch(
"pipeline_message.get_pipeline_status", new_callable=AsyncMock
) as mock_get_pipeline_status:
mock_get_pipeline_status.return_value = "success"
message = await main(pipeline_id, project_id)
assert (
message == ""
), f"Expected no message for successful pipeline, but got: {message}"
@pytest.mark.asyncio
async def test_single_problem_job_not_summarized():
session = AsyncMock()
project_id = "176"
problem_jobs = [
{
"id": 1234,
"name": "test-job",
"web_url": "http://example.com/job/1234",
"status": "canceled",
}
]
mock_response = AsyncMock()
mock_response.status = 200
mock_response.text.return_value = "" # Empty CSV response for test
session.get.return_value = mock_response
message = await process_problem_jobs(session, project_id, problem_jobs)
assert "summary" not in message
assert "[test-job](http://example.com/job/1234)" in message
@pytest.mark.asyncio
@patch("pipeline_message.get_project_json", new_callable=AsyncMock)
@patch("pipeline_message.aiohttp.ClientSession", autospec=True)
async def test_get_trace_failures_no_response(
mock_client_session_cls, mock_get_project_json, caplog
):
caplog.set_level(logging.DEBUG)
namespace = "mesa"
mock_get_project_json.return_value = {"path": namespace}
mock_get = AsyncMock()
mock_get.status = 404
mock_session_instance = mock_client_session_cls.return_value
mock_session_instance.get.return_value = mock_get
job_id = 12345678
job = {"id": job_id}
url = await get_trace_failures(mock_session_instance, "176", job)
assert url == ""
expected_log_message = f"No response from: https://mesa.pages.freedesktop.org/-/{namespace}/-/jobs/{job_id}/artifacts/results/summary/problems.html"
assert any(expected_log_message in record.message for record in caplog.records)
@pytest.mark.asyncio
@patch("pipeline_message.get_job_log", new_callable=AsyncMock)
async def test_search_job_log_for_errors(mock_get_job_log):
session = AsyncMock()
project_id = "176"
job = {"id": 12345}
job_log = r"""
error_msg: something useful
[0m15:41:36.102: GL_KHR_no_error GL_KHR_texture_compression_astc_sliced_3d
1 error generated
3 errors generated.
-- Looking for strerror_r - found
-- Looking for strerror_s - not found
[49/176] Building CXX object lib/Support/CMakeFiles/LLVMSupport.dir/ErrorHandling.cpp.o
[127/2034] Building C object lib/Support/CMakeFiles/LLVMSupport.dir/regerror.c.o
-- Performing Test HAS_WERROR_GLOBAL_CTORS
-- Performing Test C_SUPPORTS_WERROR_UNGUARDED_AVAILABILITY_NEW - Success
-- Performing Test LLVM_LIBSTDCXX_SOFT_ERROR
error aborting
error_msg : None
error_type : Job
[0Ksection_end:1734694783:job_data
[0K
[0m11:39:43.438: [1mFinished executing LAVA job in the attempt #3 [0m
[0Ksection_end:1734694783:lava_submit
[0K
[0;31m[01:54] ERROR: lava_submit: ret code: 1 [0m
[0;31m[01:54] ERROR: unknown-section: ret code: 1 [0m
section_end:1734694783:step_script
[0Ksection_start:1734694783:after_script
[0K[0K[36;1mRunning after_script[0;m[0;m
[32;1mRunning after script...[0;m
[32;1m$ curl -L --retry 4 -f --retry-all-errors --retry-delay 60 -s "https://" | tar --warning=no-timestamp --zstd -x[0;m
zstd: /*stdin*\: unexpected end of file # noqa: W605
tar: Child returned status 1
tar: Error is not recoverable: exiting now
section_end:1734695025:after_script
[0K[0;33mWARNING: after_script failed, but job will continue unaffected: exit code 1[0;m
section_start:1734695025:upload_artifacts_on_failure
[0K[0K[36;1mUploading artifacts for failed job[0;m[0;m
[32;1mUploading artifacts...[0;m
results/: found 11 matching artifact files and directories[0;m
Uploading artifacts as "archive" to coordinator... 201 Created[0;m id[0;m=68509685 responseStatus[0;m=201 Created token[0;m=glcbt-64
[32;1mUploading artifacts...[0;m
[0;33mWARNING: results/junit.xml: no matching files. Ensure that the artifact path is relative to the working directory (/builds/mesa/mesa)[0;m
[31;1mERROR: No files to upload [0;m
section_end:1734695027:upload_artifacts_on_failure
[0Ksection_start:1734695027:cleanup_file_variables
[0K[0K[36;1mCleaning up project directory and file based variables[0;m[0;m
section_end:1734695027:cleanup_file_variables
[0K[31;1mERROR: Job failed: exit code 1
[0;m
[0;m
"""
mock_get_job_log.return_value = job_log
error_message = await search_job_log_for_errors(session, project_id, job)
assert "something useful" in error_message
@pytest.mark.asyncio
@patch("pipeline_message.get_job_log", new_callable=AsyncMock)
async def test_search_job_log_for_fatal_errors(mock_get_job_log):
session = AsyncMock()
project_id = "176"
job = {"id": 12345}
job_log = r"""
[0m15:41:36.105: [15:41:31.951] fatal: something fatal
Uploading artifacts as "archive" to coordinator... 201 Created[0;m id[0;m=68509685 responseStatus[0;m=201 Created token[0;m=glcbt-64
[32;1mUploading artifacts...[0;m
[0;33mWARNING: results/junit.xml: no matching files. Ensure that the artifact path is relative to the working directory (/builds/mesa/mesa)[0;m
[31;1mERROR: No files to upload [0;m
section_end:1734695027:upload_artifacts_on_failure
[0Ksection_start:1734695027:cleanup_file_variables
[0K[0K[36;1mCleaning up project directory and file based variables[0;m[0;m
section_end:1734695027:cleanup_file_variables
[0K[31;1mERROR: Job failed: exit code 1
[0;m
[0;m
"""
mock_get_job_log.return_value = job_log
error_message = await search_job_log_for_errors(session, project_id, job)
assert "something fatal" in error_message
@pytest.mark.asyncio
@patch("pipeline_message.get_job_log", new_callable=AsyncMock)
async def test_search_job_log_for_errors_but_find_none(mock_get_job_log):
session = AsyncMock()
project_id = "176"
job = {"id": 12345}
job_log = r"""
[0KRunning with gitlab-runner 17.4.0 (b92ee590)[0;m
[0K on fdo-equinix-m3l-30-placeholder_63 XmDXAt7xd, system ID: s_785ae19292ea[0;m
section_start:1734736110:prepare_executor
[0K[0K[36;1mPreparing the "docker" executor[0;m[0;m
[0KUsing Docker executor with image registry.freedesktop.org/mesa/mesa/debian
[0KAuthenticating with credentials from job payload (GitLab Registry)[0;m
[0KPulling docker image registry.freedesktop.org/mesa/mesa/debian/x86_64_pyuti
[0KUsing docker image sha256:ebc7b3fe89be4d390775303adddb33539c235a2663165d78d
[0Ksection_start:1734736124:prepare_script
[0K[0K[36;1mPreparing environment[0;m[0;m
Running on runner-xmdxat7xd-project-23076-concurrent-1 via fdo-equinix-m3l-30...
section_end:1734736125:prepare_script
[0Ksection_start:1734736125:get_sources
[0K[0K[36;1mGetting source from Git repository[0;m[0;m
[32;1m$ /host/bin/curl -s -L --cacert /host/ca-certificates.crt --retry 4 -f --retry-delay 60 https://gitlab.
Checking if the user of the pipeline is allowed...
Checking if the job's project is part of a well-known group...
Checking if the job is part of an official MR pipeline...
Thank you for contributing to freedesktop.org
Running pre-clone script: 'set -o xtrace
wget -q -O download-git-cache.sh https://gitlab.freedesktop.org/mesa/mesa/-/raw/0d43b4cba639b809ad0e08a065ce01846e262249/.gitlab-ci/download-git-cache.sh
bash download-git-cache.sh
rm download-git-cache.sh
[31;1m errors
[0K[31;1mERROR:
[31;1m error
[31;1m Here is a blank error:
/builds/mesa/mesa/bin/ci/test/test_pipeline_message.py:162: AssertionError
Uploading artifacts as "archive" to coordinator... 201 Created[0;m id[0;m=68509685 responseStatus[0;m=201 Created token[0;m=glcbt-64
[32;1mUploading artifacts...[0;m
[0;33mWARNING: results/junit.xml: no matching files. Ensure that the artifact path is relative to the working directory (/builds/mesa/mesa)[0;m
[31;1mERROR: No files to upload [0;m
section_end:1734695027:upload_artifacts_on_failure
[0Ksection_start:1734695027:cleanup_file_variables
[0K[0K[36;1mCleaning up project directory and file based variables[0;m[0;m
section_end:1734695027:cleanup_file_variables
[0K[31;1mERROR: Job failed: exit code 1
[0;m
[0;m
"""
mock_get_job_log.return_value = job_log
error_message = await search_job_log_for_errors(session, project_id, job)
assert error_message == "", f"Unexpected error message: {error_message}"
+182
View File
@@ -0,0 +1,182 @@
import json
from pathlib import Path
import pytest
from mock import MagicMock, patch
from structured_logger import (
AutoSaveDict,
CSVStrategy,
JSONStrategy,
StructuredLogger,
YAMLStrategy,
)
@pytest.fixture(params=[CSVStrategy, JSONStrategy, YAMLStrategy])
def strategy(request):
return request.param
@pytest.fixture
def file_extension(strategy):
if strategy == CSVStrategy:
return "csv"
elif strategy == JSONStrategy:
return "json"
elif strategy == YAMLStrategy:
return "yaml"
@pytest.fixture
def tmp_file(tmp_path):
return tmp_path / "test.json"
def test_guess_strategy_from_file(tmp_path, strategy, file_extension):
file_name = tmp_path / f"test_guess.{file_extension}"
Path(file_name).touch()
guessed_strategy = StructuredLogger.guess_strategy_from_file(file_name)
assert isinstance(guessed_strategy, strategy)
def test_get_strategy(strategy, file_extension):
result = StructuredLogger.get_strategy(file_extension)
assert isinstance(result, strategy)
def test_invalid_file_extension(tmp_path):
file_name = tmp_path / "test_invalid.xyz"
Path(file_name).touch()
with pytest.raises(ValueError, match="Unknown strategy for: xyz"):
StructuredLogger.guess_strategy_from_file(file_name)
def test_non_existent_file(tmp_path, strategy, file_extension):
file_name = tmp_path / f"non_existent.{file_extension}"
logger = StructuredLogger(file_name, strategy())
assert logger.file_path.exists()
assert "_timestamp" in logger._data
@pytest.fixture
def structured_logger_module():
with patch.dict("sys.modules", {"polars": None, "ruamel.yaml": None}):
import importlib
import structured_logger
importlib.reload(structured_logger)
yield structured_logger
def test_missing_csv_library(tmp_path, structured_logger_module):
with pytest.raises(RuntimeError, match="Can't parse CSV files. Missing library"):
structured_logger_module.CSVStrategy()
def test_missing_yaml_library(tmp_path, structured_logger_module):
with pytest.raises(RuntimeError, match="Can't parse YAML files. Missing library"):
structured_logger_module.YAMLStrategy()
def test_autosavedict_setitem():
save_callback = MagicMock()
d = AutoSaveDict(save_callback=save_callback)
d["key"] = "value"
assert d["key"] == "value"
save_callback.assert_called_once()
def test_autosavedict_delitem():
save_callback = MagicMock()
d = AutoSaveDict({"key": "value"}, save_callback=save_callback)
del d["key"]
assert "key" not in d
save_callback.assert_called_once()
def test_autosavedict_pop():
save_callback = MagicMock()
d = AutoSaveDict({"key": "value"}, save_callback=save_callback)
result = d.pop("key")
assert result == "value"
assert "key" not in d
save_callback.assert_called_once()
def test_autosavedict_update():
save_callback = MagicMock()
d = AutoSaveDict({"key": "old_value"}, save_callback=save_callback)
d.update({"key": "new_value"})
assert d["key"] == "new_value"
save_callback.assert_called_once()
def test_structured_logger_setitem(tmp_file):
logger = StructuredLogger(tmp_file, JSONStrategy())
logger.data["field"] = "value"
with open(tmp_file, "r") as f:
data = json.load(f)
assert data["field"] == "value"
def test_structured_logger_set_recursive(tmp_file):
logger = StructuredLogger(tmp_file, JSONStrategy())
logger.data["field"] = {"test": True}
other = logger.data["field"]
other["late"] = True
with open(tmp_file, "r") as f:
data = json.load(f)
assert data["field"]["test"]
assert data["field"]["late"]
def test_structured_logger_set_list(tmp_file):
logger = StructuredLogger(tmp_file, JSONStrategy())
logger.data["field"] = [True]
other = logger.data["field"]
other.append(True)
with open(tmp_file, "r") as f:
data = json.load(f)
assert data["field"][0]
assert data["field"][1]
def test_structured_logger_delitem(tmp_file):
logger = StructuredLogger(tmp_file, JSONStrategy())
logger.data["field"] = "value"
del logger.data["field"]
with open(tmp_file, "r") as f:
data = json.load(f)
assert "field" not in data
def test_structured_logger_pop(tmp_file):
logger = StructuredLogger(tmp_file, JSONStrategy())
logger.data["field"] = "value"
logger.data.pop("field")
with open(tmp_file, "r") as f:
data = json.load(f)
assert "field" not in data
def test_structured_logger_update(tmp_file):
logger = StructuredLogger(tmp_file, JSONStrategy())
logger.data.update({"field": "value"})
with open(tmp_file, "r") as f:
data = json.load(f)
assert data["field"] == "value"
+391
View File
@@ -0,0 +1,391 @@
import sys
import subprocess
import yaml
import pytest
from textwrap import dedent
from unittest.mock import patch
from bin.ci.update_tag import (
from_component_to_build_tag,
filter_components,
from_component_to_tag_var,
from_script_name_to_component,
from_script_name_to_tag_var,
load_image_tags_yaml,
update_image_tag_in_yaml,
run_build_script,
main,
PATHS,
)
@pytest.fixture
def temp_image_tags_file(tmp_path, monkeypatch):
"""
Fixture that creates a temporary YAML file (to simulate conditional_tags)
and updates the global variable accordingly.
"""
# Create a temporary test file
temp_file = tmp_path / "conditional-build-image-tags.yml"
temp_file.write_text(yaml.dump({"variables": {}}))
# Create a copy of the original PATHS values
original_conditional_tags = PATHS.conditional_tags
# Patch the PATHS instance with our test file
PATHS.conditional_tags = temp_file
# Yield the test file for the test to use
yield temp_file
# Restore the original value after the test
PATHS.conditional_tags = original_conditional_tags
@pytest.fixture
def temp_container_ci_file(tmp_path, monkeypatch):
"""
Fixture that creates a temporary container CI file (to simulate container_ci)
and updates the global variable accordingly.
"""
# Create a temporary test file
temp_file = tmp_path / "container-ci.yml"
temp_file.touch()
# Create a copy of the original PATHS values
original_container_ci = PATHS.container_ci
# Patch the PATHS instance with our test file
PATHS.container_ci = temp_file
# Yield the test file for the test to use
yield temp_file
# Restore the original value after the test
PATHS.container_ci = original_container_ci
@pytest.fixture
def mock_container_dir(tmp_path, monkeypatch):
"""
Fixture that creates a dummy container directory and build script.
"""
# Create a dummy container directory
container_dir = tmp_path / "container"
container_dir.mkdir()
# Create a dummy setup-test-env.sh file
dummy_setup_path = tmp_path / "setup-test-env.sh"
dummy_setup_path.write_text("echo Setup")
# Save original values
original_container_dir = PATHS.container_dir
original_setup_test_env = PATHS.setup_test_env
# Patch the PATHS instance
PATHS.container_dir = container_dir
PATHS.setup_test_env = dummy_setup_path
# Yield the directory for the test to use
yield container_dir
# Restore original values
PATHS.container_dir = original_container_dir
PATHS.setup_test_env = original_setup_test_env
@pytest.fixture
def mock_build_script(mock_container_dir):
"""
Fixture that creates a dummy build script in the container directory.
"""
build_script = mock_container_dir / "build-foo.sh"
return build_script
###############################################################################
# Tests for argument parsing and helper functions
###############################################################################
@pytest.mark.parametrize(
"component, expected_tag_var",
[
("foo", "FOO_TAG"),
("my-component", "MY_COMPONENT_TAG"),
("foo-bar-baz", "FOO_BAR_BAZ_TAG"),
],
)
def test_from_component_to_tag_var(component, expected_tag_var):
"""
Test that from_component_to_tag_var returns the correct tag variable name.
"""
assert from_component_to_tag_var(component) == expected_tag_var
@pytest.mark.parametrize(
"component, expected_build_tag",
[
("foo", "CONDITIONAL_BUILD_FOO_TAG"),
("my-component", "CONDITIONAL_BUILD_MY_COMPONENT_TAG"),
("foo-bar-baz", "CONDITIONAL_BUILD_FOO_BAR_BAZ_TAG"),
],
)
def test_from_component_to_build_tag(component, expected_build_tag):
"""
Test that from_component_to_build_tag returns the correct build tag name.
"""
assert from_component_to_build_tag(component) == expected_build_tag
@pytest.mark.parametrize(
"script_name, expected_component",
[
("build-foo.sh", "foo"),
("build-my-component.sh", "my-component"),
("build-foo-bar-baz.sh", "foo-bar-baz"),
],
)
def test_from_script_name_to_component(script_name, expected_component):
"""
Test that from_script_name_to_component returns the correct component name.
"""
assert from_script_name_to_component(script_name) == expected_component
@pytest.mark.parametrize(
"script_name, expected_tag_var",
[
("build-foo.sh", "FOO_TAG"),
("build-my-component.sh", "MY_COMPONENT_TAG"),
("build-foo-bar-baz.sh", "FOO_BAR_BAZ_TAG"),
],
)
def test_from_script_name_to_tag_var(script_name, expected_tag_var):
"""
Test that from_script_name_to_tag_var returns the correct tag variable name.
"""
assert from_script_name_to_tag_var(script_name) == expected_tag_var
def test_filter_components():
"""
Test that filter_components returns only components that match an include regex
and do not match any exclude regex. If includes is empty, an empty list is returned.
"""
components = ["alpha", "beta", "gamma", "delta"]
# When includes is empty, should return an empty list.
assert filter_components(components, [], []) == []
# Test includes only.
# Components that start with 'a' or 'b'
expected = ["alpha", "beta"]
result = filter_components(components, ["^[a-b].*$"], [])
assert result == expected
# Test with an exclude that filters out "alpha" (which matches "lph").
result = filter_components(components, ["^[a-b].*$"], ["^.*lph.*$"])
# "alpha" is removed.
assert result == ["beta"]
@pytest.mark.parametrize(
"component, tag",
[
# Basic case
("test", "new_tag"),
# Component with hyphen
("my-component", "v1.2.3"),
# Uppercase component name
("UpperCase", "123"),
# Numbers in component name
("123service", "build-456"),
],
)
def test_update_image_tag_in_yaml(component, tag, temp_image_tags_file):
"""
Test multiple updates with different component names and tags, verifying:
1. Correct variable name generation
2. Proper tag value storage
3. Maintained sorting of variables
"""
# Initial update
update_image_tag_in_yaml(component, tag)
data = load_image_tags_yaml()
expected_var = from_component_to_build_tag(component)
assert data["variables"][expected_var] == tag
# Add second component and verify both exist
update_image_tag_in_yaml("another_component", "secondary_tag")
updated_data = load_image_tags_yaml()
assert from_component_to_build_tag("another_component") in updated_data["variables"]
assert updated_data["variables"][expected_var] == tag # Original value remains
# Verify sorting
variables = list(data["variables"].keys())
assert len(updated_data["variables"]) == len(variables) + 1
assert variables == sorted(variables), "Variables are not alphabetically sorted"
def test_if_run_extracts_the_tag_from_stdout(monkeypatch, mock_build_script):
"""
Test that run_build_script returns the tag (last stdout line) when the build
script executes successfully.
"""
mock_build_script.write_text("#!/bin/bash\necho Build script\necho new_tag")
# Create a fake subprocess.CompletedProcess to simulate a successful build.
fake_result = subprocess.CompletedProcess(
args=[], returncode=0, stdout="line1\nnew_tag", stderr=""
)
monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: fake_result)
tag = run_build_script("foo", check_only=False)
assert tag == "new_tag"
def test_running_real_process_works(monkeypatch, mock_build_script):
"""
Test that run_build_script returns the tag (last stdout line) when the build
script executes successfully.
"""
mock_build_script.write_text("echo Build script\necho new_tag")
tag = run_build_script("foo", check_only=False)
assert tag == "new_tag"
###############################################################################
# Tests for main() argument features
###############################################################################
def setup_build_script_and_container_ci_file(container_dir, container_ci_file, comp):
tag_var = from_component_to_build_tag(comp)
build_check_var = from_component_to_tag_var(comp)
build_script_path = container_dir / f"build-{comp}.sh"
build_script_path.write_text(
dedent(
f"""
#!/bin/bash
ci_tag_build_time_check {tag_var}
echo "new_tag"
"""
)
)
container_ci_file.write_text(
container_ci_file.read_text()
+ dedent(
f"""
.container-builds-{comp}:
variables:
{tag_var}: "${{{build_check_var}}}"
"""
)
)
def test_main_list(monkeypatch, capsys, mock_container_dir, temp_container_ci_file):
"""
Test that when the --list argument is provided, main() prints all detected
components
"""
# Monkeypatch find_components to return a known list.
for comp in ["comp-a", "comp-b"]:
setup_build_script_and_container_ci_file(mock_container_dir, temp_container_ci_file, comp)
# Set sys.argv to simulate passing --list.
monkeypatch.setattr(sys, "argv", ["bin/ci/update_tag.py", "--list"])
main()
captured = capsys.readouterr().out
assert "Detected components:" in captured
assert "comp-a" in captured
assert "comp-b" in captured
def test_main_check(monkeypatch, temp_image_tags_file, temp_container_ci_file):
"""
Test that when --check is provided, main() calls run_build_script in check mode.
"""
with patch(
"bin.ci.update_tag.run_build_script", return_value="dummy_tag"
) as mock_run_build_script:
# Simulate command line: --check compX
monkeypatch.setattr(sys, "argv", ["bin/ci/update_tag.py", "--check", "comp-x"])
main()
# Verify run_build_script was called with check_only=True.
assert mock_run_build_script.call_count == 1
assert "comp-x" in mock_run_build_script.call_args.args[0]
assert mock_run_build_script.call_args.kwargs["check_only"] is True
EXIT_CODE_SCENARIOS = {
"unbound_variable": (
"line 2: UNDEFINED_VARIABLE: unbound variable",
127,
3,
"Please set the variable UNDEFINED_VARIABLE",
),
"build script error": (
"",
50,
50,
"",
),
"tag_mismatch": (
"Tag mismatch for foo.",
2,
2,
"Tag mismatch for foo.",
),
}
@pytest.mark.parametrize(
"stderr_content, script_returncode, expected_exit_code, expected_error_message",
EXIT_CODE_SCENARIOS.values(),
ids=list(EXIT_CODE_SCENARIOS.keys()),
)
def test_build_script_error_exit_codes(
monkeypatch,
mock_build_script,
capsys,
stderr_content,
script_returncode,
expected_exit_code,
expected_error_message,
):
"""
Test that build script errors generate appropriate exit codes:
1 - Unhandled error in this script
2 - Tag mismatch when using --check
3 - Unbound variable error in build script
x - Build script failed with return code x
"""
# Create a build script that will fail
mock_build_script.write_text("#!/bin/bash\necho 'This will fail'")
# Create a fake subprocess.CompletedProcess to simulate a failed build
fake_result = subprocess.CompletedProcess(
args=[], returncode=script_returncode, stdout="", stderr=stderr_content
)
monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: fake_result)
monkeypatch.setattr(sys, "argv", ["bin/ci/update_tag.py", "--check", "foo"])
monkeypatch.setattr("bin.ci.update_tag.get_current_tag_value", lambda *args: "current_tag")
# Mock sys.exit to capture the exit code instead of exiting the test
with pytest.raises(SystemExit) as e:
main()
# Check for expected error message if one is specified
if expected_error_message:
captured = capsys.readouterr()
assert expected_error_message in captured.err
# Verify correct exit code
assert e.value.code == expected_exit_code