blob: c8ee6d653e7fe32ab66471e9fd4e357c11b8e40b [file] [log] [blame]
#!/usr/bin/env vpython3
# Copyright 2025 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import unittest
import json
import sys
import os
import tempfile
import shutil
from unittest.mock import patch, MagicMock
# We need to adjust the path to import the script directly.
# This assumes the test script is in the same directory as the main script.
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
import decisiongraph_invoker as dgi
import requests
class TestDecisionGraphInvoker(unittest.TestCase):
def setUp(self):
self.maxDiff = None
self.test_dir = tempfile.mkdtemp()
self.config_data = {
'build_id': '12345',
'change': 123456,
'patchset': 1,
'builder': 'chromium-commit-queue',
'api_key': 'test_api_key_123'
}
self.config_file_path = os.path.join(self.test_dir, 'test_config.json')
self.api_url_with_key = f"{dgi.API_URL}?key={self.config_data['api_key']}"
def tearDown(self):
shutil.rmtree(self.test_dir)
def _create_mock_config_file(self, data):
"""Helper to create a mock JSON config file."""
with open(self.config_file_path, 'w', encoding='utf-8') as f:
json.dump(data, f)
@patch('builtins.print')
def test_load_config_from_json_success(self, mock_print):
self._create_mock_config_file(self.config_data)
config = dgi.load_config_from_json(self.config_file_path)
self.assertEqual(config, self.config_data)
@patch('sys.exit')
@patch('builtins.print')
def test_load_config_from_json_file_not_found(self, mock_print, mock_exit):
dgi.load_config_from_json('non_existent_file.json')
mock_print.assert_any_call(
'Error: Configuration file not found at non_existent_file.json')
mock_exit.assert_called_with(1)
@patch('sys.exit')
@patch('builtins.print')
def test_load_config_from_json_missing_args(self, mock_print, mock_exit):
incomplete_config = {'build_id': '123', 'change': 1}
self._create_mock_config_file(incomplete_config)
dgi.load_config_from_json(self.config_file_path)
mock_print.assert_any_call(
'Error: Missing required arguments in JSON config file.')
mock_exit.assert_called_with(1)
@patch('sys.exit')
@patch('builtins.print')
def test_load_config_from_json_invalid_type(self, mock_print, mock_exit):
invalid_config = self.config_data.copy()
invalid_config['change'] = 'not_an_int'
self._create_mock_config_file(invalid_config)
dgi.load_config_from_json(self.config_file_path)
mock_print.assert_any_call(
"Error: Invalid data type for 'change' or 'patchset' in JSON. "
"Expected integers.")
mock_exit.assert_called_with(1)
@patch('builtins.print')
@patch('requests.post')
def test_fetch_api_data_success(self, mock_post, mock_print):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {'status': 'success'}
mock_response.text = '{"status": "success"}'
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
response = dgi.fetch_api_data(self.api_url_with_key, {})
self.assertEqual(response, {'status': 'success'})
mock_print.assert_any_call('{"status": "success"}')
mock_print.assert_any_call(200)
@patch('builtins.print')
@patch('requests.post')
def test_fetch_api_data_failure_http_error(self, mock_post, mock_print):
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = 'Internal Server Error'
mock_response.raise_for_status.side_effect = (
requests.exceptions.HTTPError('500 Server Error'))
mock_post.return_value = mock_response
response = dgi.fetch_api_data(self.api_url_with_key, {})
self.assertIsNone(response)
mock_print.assert_any_call('An error occurred: 500 Server Error')
mock_print.assert_any_call('Internal Server Error')
mock_print.assert_any_call(500)
def test_overwrite_filter_file_success(self):
test_suite = 'browser_tests'
tests_to_skip = ['Test1.testA', 'Test2.testB']
result = dgi.overwrite_filter_file(self.test_dir, test_suite, tests_to_skip)
self.assertTrue(result)
filter_file_path = os.path.join(self.test_dir, f'{test_suite}.filter')
self.assertTrue(os.path.exists(filter_file_path))
with open(filter_file_path, 'r', encoding='utf-8') as f:
content = f.read()
self.assertIn('-Test1.testA', content)
self.assertIn('-Test2.testB', content)
def test_overwrite_filter_file_appends_wildcard(self):
"""Tests that a wildcard '*' is appended to each test name."""
test_suite = 'browser_tests'
tests_to_skip = ['TestClassOne', 'TestClassTwo']
result = dgi.overwrite_filter_file(self.test_dir, test_suite, tests_to_skip)
self.assertTrue(result)
filter_file_path = os.path.join(self.test_dir, f'{test_suite}.filter')
with open(filter_file_path, 'r', encoding='utf-8') as f:
content = f.read()
expected_content = ('# A list of tests to be skipped, generated by test'
' selection.\n'
'-TestClassOne.*\n'
'-TestClassTwo.*\n')
self.assertEqual(content, expected_content)
@patch('builtins.print')
def test_overwrite_filter_file_dir_not_found(self, mock_print):
result = dgi.overwrite_filter_file('non_existent_dir', 'suite', [])
self.assertFalse(result)
mock_print.assert_called_with(
'Error: Filter file directory not found at non_existent_dir')
@patch('sys.exit')
@patch('decisiongraph_invoker.load_config_from_json')
@patch('decisiongraph_invoker.fetch_api_data')
def test_main_trigger_phase_success(self, mock_fetch, mock_load_config,
mock_exit):
mock_load_config.return_value = self.config_data
mock_fetch.return_value = {'output': [{'result': 'success'}]}
with patch('sys.argv', [
'decisiongraph_invoker.py', '--test-targets', 'browser_tests',
'--sts-config-file', self.config_file_path, '--test-selection-phase',
'TRIGGER'
]):
dgi.main()
mock_fetch.assert_called_once()
_, kwargs = mock_fetch.call_args
payload = kwargs['json_payload']
self.assertTrue(
payload['graph']['stages'][0]['execution_options']['prepare'])
mock_exit.assert_called_with(0)
@patch('sys.exit')
@patch('decisiongraph_invoker.load_config_from_json')
@patch('decisiongraph_invoker.fetch_api_data')
def test_main_fetch_phase_success(self, mock_fetch, mock_load_config,
mock_exit):
mock_load_config.return_value = self.config_data
mock_fetch.return_value = {
'outputs': [{
'checks': [{
'identifier': {
'luciTest': {
'testSuite': 'browser_tests'
}
},
'children': [{
'identifier': {
'luciTest': {
'testId': 'TestClass.testExample1'
}
}
}, {
'identifier': {
'luciTest': {
'testId': 'TestClass.testExample2'
}
}
}]
}]
}]
}
with patch('sys.argv', [
'decisiongraph_invoker.py', '--test-targets', 'browser_tests',
'--sts-config-file', self.config_file_path, '--test-selection-phase',
'FETCH', '--filter-file-dir', self.test_dir
]):
dgi.main()
mock_fetch.assert_called_once()
_, kwargs = mock_fetch.call_args
payload = kwargs['json_payload']
self.assertFalse(
payload['graph']['stages'][0]['execution_options']['prepare'])
filter_file_path = os.path.join(self.test_dir, 'browser_tests.filter')
self.assertTrue(os.path.exists(filter_file_path))
with open(filter_file_path, 'r', encoding='utf-8') as f:
content = f.read()
self.assertIn('-TestClass.testExample1', content)
self.assertIn('-TestClass.testExample2', content)
mock_exit.assert_called_with(0)
@patch('sys.exit')
@patch('decisiongraph_invoker.load_config_from_json')
@patch('decisiongraph_invoker.fetch_api_data')
def test_main_fetch_phase_key_error(self, mock_fetch, mock_load_config,
mock_exit):
mock_load_config.return_value = self.config_data
# Malformed response missing 'testId'
mock_fetch.return_value = {
'outputs': [{
'checks': [{
'identifier': {
'luciTest': {
'testSuite': 'browser_tests'
}
},
'children': [{
'identifier': {
'luciTest': {}
}
}]
}]
}]
}
with patch('sys.argv', [
'decisiongraph_invoker.py', '--test-targets', 'browser_tests',
'--sts-config-file', self.config_file_path, '--test-selection-phase',
'FETCH', '--filter-file-dir', self.test_dir
]):
with self.assertRaises(KeyError):
dgi.main()
@patch('argparse.ArgumentParser.error')
def test_main_fetch_phase_missing_dir_arg(self, mock_parser_error):
# Make the mock raise an exception to halt execution, which is what
# parser.error() does internally by calling sys.exit().
mock_parser_error.side_effect = SystemExit
with self.assertRaises(SystemExit):
with patch('sys.argv', [
'decisiongraph_invoker.py',
'--test-targets',
'browser_tests',
'--sts-config-file',
self.config_file_path,
'--test-selection-phase',
'FETCH',
]):
dgi.main()
# Verify that parser.error was called with the correct message.
mock_parser_error.assert_called_with(
'--filter-file-dir is required when phase is FETCH.')
if __name__ == '__main__':
unittest.main()