| #!/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() |