blob: c9d70abb1d3ed3a76e910051528532a1b9111a2a [file]
import unittest
from unittest.mock import patch, MagicMock, mock_open
import os
import json
import tempfile
import base64
import collect_metrics
def create_mock_bep_content(test_results=None, exit_code=0):
content = []
# 1. Optional Test Results
if test_results:
for tr in test_results:
content.append(json.dumps({
"id": {"testResult": {"label": tr["label"]}},
"testResult": {"status": tr["status"], "testAttemptDurationMillis": str(tr["duration_ms"])},
}))
# 2. Common Build Metrics
content.append(json.dumps({
"id": {"buildMetrics": {}},
"buildMetrics": {
"timingMetrics": {"wallTimeInMs": "10000"},
"actionSummary": {
"actionsExecuted": "100",
"runnerCount": [{"name": "remote cache hit", "count": "50"}],
},
"artifactMetrics": {"topLevelArtifacts": {"sizeInBytes": "1024"}},
"networkMetrics": {"systemNetworkStats": {"bytesRecv": "512"}},
},
}))
# 3. Common Build Tool Logs (Critical Path)
content.append(json.dumps({
"id": {"buildToolLogs": {}},
"buildToolLogs": {
"log": [{
"name": "critical path",
"contents": base64.b64encode(b"Critical Path: 15.0s\n").decode("utf-8"),
}]
},
}))
# 4. Common Build Finished
content.append(json.dumps({
"id": {"buildFinished": {}},
"finished": {"exitCode": {"code": exit_code}}
}))
return content
class TestPublishMetrics(unittest.TestCase):
def setUp(self):
# Reset environment variables before each test
self.original_environ = os.environ.copy()
os.environ.clear()
def tearDown(self):
os.environ.clear()
os.environ.update(self.original_environ)
# --- Test 1: Git Stats Logic ---
@patch("subprocess.check_output")
def test_get_git_stats_success(self, mock_subprocess):
# Mock successful git output
mock_subprocess.return_value = " 5 files changed, 20 insertions(+), 5 deletions(-)"
count = collect_metrics.get_git_stats()
self.assertEqual(count, 5)
@patch("subprocess.check_output")
def test_get_git_stats_singular(self, mock_subprocess):
# Mock singular output
mock_subprocess.return_value = " 1 file changed, 1 insertion(+)"
count = collect_metrics.get_git_stats()
self.assertEqual(count, 1)
@patch("subprocess.check_output")
def test_get_git_stats_failure(self, mock_subprocess):
# Mock a git failure
mock_subprocess.side_effect = Exception("Git command not found")
count = collect_metrics.get_git_stats()
self.assertEqual(count, 9999) # Fallback value
# --- Test 2: BEP Parsing ---
def test_parse_bep_valid(self):
# Create a mock BEP file content
test_results = [
{"label": "//pkg:test1", "status": "PASSED", "duration_ms": 1500},
{"label": "//pkg:test2", "status": "FAILED", "duration_ms": 5000},
]
mock_bep_content = create_mock_bep_content(test_results=test_results)
# Create a temp file to hold the data
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf:
tf.write("\n".join(mock_bep_content))
temp_path = tf.name
try:
bep_metrics = collect_metrics.parse_bep(temp_path)
# Verify Metrics
self.assertEqual(bep_metrics.wall_time_ms, 10000)
self.assertEqual(bep_metrics.total_actions, 100)
self.assertEqual(bep_metrics.remote_and_disk_cache_hits, 50)
self.assertEqual(bep_metrics.failed_test_count, 1)
self.assertEqual(bep_metrics.critical_path_s, 15.0)
self.assertEqual(bep_metrics.exit_code, 0)
# Verify Targets
self.assertEqual(len(bep_metrics.targets), 2)
target1 = next(t for t in bep_metrics.targets if t.label == "//pkg:test1")
self.assertEqual(target1.status, "PASSED")
self.assertEqual(target1.duration_s, 1.5)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
# --- Second Run to fall back to outputArtifactsSeen ---
mock_bep_content_2 = [
json.dumps(
{
"id": {"buildMetrics": {}},
"buildMetrics": {
"artifactMetrics": {
"topLevelArtifacts": {"sizeInBytes": "0"},
"outputArtifactsSeen": {"sizeInBytes": "4096"}
},
},
}
),
]
# Create a temp file to hold the data
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf2:
tf2.write("\n".join(mock_bep_content_2))
temp_path_2 = tf2.name
try:
bep_metrics_2 = collect_metrics.parse_bep(temp_path_2)
self.assertEqual(bep_metrics_2.output_size_bytes, 4096)
finally:
if os.path.exists(temp_path_2):
os.remove(temp_path_2)
def test_parse_bep_build_only(self):
# Create a mock BEP file content with only build metrics
mock_bep_content = create_mock_bep_content()
# Create a temp file to hold the data
with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf:
tf.write("\n".join(mock_bep_content))
temp_path = tf.name
try:
bep_metrics = collect_metrics.parse_bep(temp_path)
# Verify Metrics
self.assertEqual(bep_metrics.wall_time_ms, 10000)
self.assertEqual(bep_metrics.total_actions, 100)
self.assertEqual(bep_metrics.remote_and_disk_cache_hits, 50)
self.assertEqual(bep_metrics.failed_test_count, 0)
self.assertEqual(bep_metrics.critical_path_s, 15.0)
self.assertEqual(bep_metrics.exit_code, 0)
# Verify Targets are empty
self.assertEqual(len(bep_metrics.targets), 0)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
def test_extract_critical_path(self):
# Mock a Base64 encoded critical path log
raw_log = "Critical Path: 12.5s\n Action A..."
b64_log = base64.b64encode(raw_log.encode("utf-8")).decode("utf-8")
logs = [{"name": "critical path", "contents": b64_log}]
duration = collect_metrics.extract_critical_path(logs)
self.assertEqual(duration, 12.5)
# --- Test 3: Main Logic & BigQuery Push ---
@patch("collect_metrics.subprocess.run")
def test_publish_to_bigquery(self, mock_run):
# Mock the subprocess run to succeed (return code 0)
mock_result = MagicMock()
mock_result.returncode = 0
mock_run.return_value = mock_result
test_row = {"build_number": 123, "pipeline": "test"}
collect_metrics.publish_to_bigquery(test_row)
# Verify it called subprocess.run with bq insert
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
self.assertTrue(any("bq" in arg for arg in call_args))
self.assertIn("insert", call_args)
@patch("collect_metrics.publish_to_bigquery")
@patch("collect_metrics.parse_bep")
@patch("collect_metrics.get_git_stats")
def test_collect_metrics_end_to_end(self, mock_git, mock_parse, mock_publish):
# Setup Environment
os.environ["BUILDKITE_BUILD_NUMBER"] = "500"
os.environ["BUILDKITE_PIPELINE_SLUG"] = "test-pipeline"
os.environ["BUILDKITE_ORGANIZATION_SLUG"] = "test-org"
# Setup Mocks
mock_git.return_value = 5 # 5 changed files
# Mock BEP Return
mock_bep_metrics = collect_metrics.BazelMetrics(
wall_time_ms=5000,
critical_path_s=4.0,
remote_and_disk_cache_hits=10,
total_actions=20,
output_size_bytes=100,
bytes_downloaded=50,
failed_test_count=0,
exit_code=0,
)
mock_parse.return_value = mock_bep_metrics
# Run Function (with mocked timestamps)
with patch("collect_metrics.fetch_job_timestamps") as mock_fetch:
mock_fetch.return_value = collect_metrics.JobTimestamps(
created_at="2023-10-25T10:00:00Z",
started_at="2023-10-25T10:05:00Z"
)
collect_metrics.collect_metrics_and_push_to_bigquery(test_bep_path="dummy_path.json")
# Verify publish_to_bigquery was called
mock_publish.assert_called_once()
# Inspect the row payload that was generated
row = mock_publish.call_args[0][0]
self.assertEqual(row["build_number"], 500)
self.assertEqual(row["pipeline"], "test-pipeline")
self.assertEqual(row["org"], "test-org")
self.assertEqual(row["changed_files_count"], 5)
self.assertEqual(row["test"]["failed_test_count"], 0)
self.assertEqual(row.get("queue_duration_s"), 300.0)
self.assertIsNone(row["task_label"])
self.assertEqual(row["build_shard_id"], -1)
self.assertEqual(row["build_shard_count"], -1)
@patch("collect_metrics.publish_to_bigquery")
@patch("collect_metrics.parse_bep")
@patch("collect_metrics.get_git_stats")
def test_collect_metrics_combined(self, mock_git, mock_parse, mock_publish):
# Setup Environment
os.environ["BUILDKITE_BUILD_NUMBER"] = "500"
os.environ["BUILDKITE_PIPELINE_SLUG"] = "test-pipeline"
os.environ["BUILDKITE_ORGANIZATION_SLUG"] = "test-org"
# Setup Mocks
mock_git.return_value = 5
# Mock BEP Return based on filename
def parse_bep_side_effect(filepath):
if "build" in filepath:
return collect_metrics.BazelMetrics(
wall_time_ms=5000,
critical_path_s=4.0,
total_actions=20,
exit_code=0,
)
elif "test" in filepath:
return collect_metrics.BazelMetrics(
wall_time_ms=10000,
critical_path_s=8.0,
total_actions=50,
exit_code=0,
failed_test_count=1,
targets=[collect_metrics.TestTarget(label="//pkg:test1", status="FAILED", duration_s=5.0)],
)
return None
mock_parse.side_effect = parse_bep_side_effect
# Run Function
with patch("collect_metrics.fetch_job_timestamps") as mock_fetch:
mock_fetch.return_value = collect_metrics.JobTimestamps(
created_at="2023-10-25T10:00:00Z",
started_at="2023-10-25T10:05:00Z",
finished_at="2023-10-25T10:15:00Z"
)
collect_metrics.collect_metrics_and_push_to_bigquery(
build_bep_path="build_bep.json", test_bep_path="test_bep.json"
)
# Verify publish_to_bigquery was called
mock_publish.assert_called_once()
# Inspect the row payload
row = mock_publish.call_args[0][0]
self.assertEqual(row["build_number"], 500)
self.assertIn("build", row)
self.assertIn("test", row)
self.assertEqual(row["build"]["wall_time_s"], 5.0)
self.assertEqual(row["test"]["wall_time_s"], 10.0)
self.assertEqual(row["test"]["failed_test_count"], 1)
self.assertEqual(len(row["test"]["targets"]), 1)
self.assertEqual(row["test"]["targets"][0]["label"], "//pkg:test1")
@patch("collect_metrics.publish_to_bigquery")
@patch("collect_metrics.parse_bep")
@patch("collect_metrics.get_git_stats")
def test_collect_metrics_sharded_task(self, mock_git, mock_parse, mock_publish):
os.environ["BUILDKITE_BUILD_NUMBER"] = "500"
os.environ["BUILDKITE_LABEL"] = "Clang on :ubuntu: Ubuntu 20.04 LTS (shard 1)"
os.environ["BUILDKITE_PARALLEL_JOB"] = "1"
os.environ["BUILDKITE_PARALLEL_JOB_COUNT"] = "3"
mock_parse.return_value = collect_metrics.BazelMetrics(wall_time_ms=5000)
with patch("collect_metrics.fetch_job_timestamps"):
collect_metrics.collect_metrics_and_push_to_bigquery(test_bep_path="dummy.json")
mock_publish.assert_called_once()
row = mock_publish.call_args[0][0]
# Verify that task_label got correctly stripped of the shard suffix and emoji
self.assertEqual(row["task_label"], "Clang on Ubuntu 20.04 LTS")
self.assertEqual(row["build_shard_id"], 1)
self.assertEqual(row["build_shard_count"], 3)
@patch("collect_metrics.subprocess.run")
def test_publish_to_bigquery_failure(self, mock_run):
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = "out"
mock_result.stderr = "err"
mock_run.return_value = mock_result
with patch("collect_metrics.print_and_annotate_warning") as mock_annotate:
collect_metrics.publish_to_bigquery({"test": 1})
mock_annotate.assert_called_once()
def test_duration_parsing_error(self):
with patch.dict(os.environ, {"CHECKOUT_DURATION_S": "invalid", "PREP_DURATION_S": "invalid", "BUILDKITE_BUILD_NUMBER": "500"}):
with patch("collect_metrics.parse_bep") as mock_parse, \
patch("collect_metrics.publish_to_bigquery"):
mock_parse.return_value = MagicMock()
collect_metrics.collect_metrics_and_push_to_bigquery("dummy")
@patch("collect_metrics.parse_bep")
def test_collect_metrics_bep_failure(self, mock_parse):
mock_parse.return_value = None
with patch.dict(os.environ, {"BUILDKITE_BUILD_NUMBER": "500"}):
with patch("collect_metrics.print_and_annotate_warning") as mock_annotate:
collect_metrics.collect_metrics_and_push_to_bigquery("dummy")
mock_annotate.assert_called_once_with("Skipping BigQuery push due to missing or failed BEP parsing.")
def test_parse_bep_file_not_found(self):
with patch("os.path.exists", return_value=False):
bep_metrics = collect_metrics.parse_bep("non_existent_file.json")
self.assertIsNone(bep_metrics)
@patch("collect_metrics.bazelci.BuildkiteClient")
def test_fetch_job_timestamps_success(self, mock_client_cls):
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.get_build_info.return_value = {
"jobs": [{"id": "123", "created_at": "C", "started_at": "S", "finished_at": "F"}]
}
ts = collect_metrics.fetch_job_timestamps("org", "pipe", 1, "123")
self.assertEqual(ts.created_at, "C")
self.assertEqual(ts.finished_at, "F")
if __name__ == "__main__":
unittest.main()