| #!/usr/bin/env python3 |
| # Copyright 2019 The ChromiumOS Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| # pylint: disable=protected-access |
| |
| """Tests for LLVM bisection.""" |
| |
| import json |
| import os |
| import subprocess |
| import unittest |
| from unittest import mock |
| |
| import chroot |
| import get_llvm_hash |
| import git_llvm_rev |
| import llvm_bisection |
| import modify_a_tryjob |
| import test_helpers |
| |
| |
| class LLVMBisectionTest(unittest.TestCase): |
| """Unittests for LLVM bisection.""" |
| |
| def testGetRemainingRangePassed(self): |
| start = 100 |
| end = 150 |
| |
| test_tryjobs = [ |
| { |
| "rev": 110, |
| "status": "good", |
| "link": "https://some_tryjob_1_url.com", |
| }, |
| { |
| "rev": 120, |
| "status": "good", |
| "link": "https://some_tryjob_2_url.com", |
| }, |
| { |
| "rev": 130, |
| "status": "pending", |
| "link": "https://some_tryjob_3_url.com", |
| }, |
| { |
| "rev": 135, |
| "status": "skip", |
| "link": "https://some_tryjob_4_url.com", |
| }, |
| { |
| "rev": 140, |
| "status": "bad", |
| "link": "https://some_tryjob_5_url.com", |
| }, |
| ] |
| |
| # Tuple consists of the new good revision, the new bad revision, a set |
| # of 'pending' revisions, and a set of 'skip' revisions. |
| expected_revisions_tuple = 120, 140, {130}, {135} |
| |
| self.assertEqual( |
| llvm_bisection.GetRemainingRange(start, end, test_tryjobs), |
| expected_revisions_tuple, |
| ) |
| |
| def testGetRemainingRangeFailedWithMissingStatus(self): |
| start = 100 |
| end = 150 |
| |
| test_tryjobs = [ |
| { |
| "rev": 105, |
| "status": "good", |
| "link": "https://some_tryjob_1_url.com", |
| }, |
| { |
| "rev": 120, |
| "status": None, |
| "link": "https://some_tryjob_2_url.com", |
| }, |
| { |
| "rev": 140, |
| "status": "bad", |
| "link": "https://some_tryjob_3_url.com", |
| }, |
| ] |
| |
| with self.assertRaises(ValueError) as err: |
| llvm_bisection.GetRemainingRange(start, end, test_tryjobs) |
| |
| error_message = ( |
| '"status" is missing or has no value, please ' |
| "go to %s and update it" % test_tryjobs[1]["link"] |
| ) |
| self.assertEqual(str(err.exception), error_message) |
| |
| def testGetRemainingRangeFailedWithInvalidRange(self): |
| start = 100 |
| end = 150 |
| |
| test_tryjobs = [ |
| { |
| "rev": 110, |
| "status": "bad", |
| "link": "https://some_tryjob_1_url.com", |
| }, |
| { |
| "rev": 125, |
| "status": "skip", |
| "link": "https://some_tryjob_2_url.com", |
| }, |
| { |
| "rev": 140, |
| "status": "good", |
| "link": "https://some_tryjob_3_url.com", |
| }, |
| ] |
| |
| with self.assertRaises(AssertionError) as err: |
| llvm_bisection.GetRemainingRange(start, end, test_tryjobs) |
| |
| expected_error_message = ( |
| "Bisection is broken because %d (good) is >= " |
| "%d (bad)" % (test_tryjobs[2]["rev"], test_tryjobs[0]["rev"]) |
| ) |
| |
| self.assertEqual(str(err.exception), expected_error_message) |
| |
| @mock.patch.object(get_llvm_hash, "GetGitHashFrom") |
| def testGetCommitsBetweenPassed(self, mock_get_git_hash): |
| start = git_llvm_rev.base_llvm_revision |
| end = start + 10 |
| test_pending_revisions = {start + 7} |
| test_skip_revisions = { |
| start + 1, |
| start + 2, |
| start + 4, |
| start + 8, |
| start + 9, |
| } |
| parallel = 3 |
| abs_path_to_src = "/abs/path/to/src" |
| |
| revs = ["a123testhash3", "a123testhash5"] |
| mock_get_git_hash.side_effect = revs |
| |
| git_hashes = [ |
| git_llvm_rev.base_llvm_revision + 3, |
| git_llvm_rev.base_llvm_revision + 5, |
| ] |
| |
| self.assertEqual( |
| llvm_bisection.GetCommitsBetween( |
| start, |
| end, |
| parallel, |
| abs_path_to_src, |
| test_pending_revisions, |
| test_skip_revisions, |
| ), |
| (git_hashes, revs), |
| ) |
| |
| def testLoadStatusFilePassedWithExistingFile(self): |
| start = 100 |
| end = 150 |
| |
| test_bisect_state = {"start": start, "end": end, "jobs": []} |
| |
| # Simulate that the status file exists. |
| with test_helpers.CreateTemporaryJsonFile() as temp_json_file: |
| with open(temp_json_file, "w", encoding="utf-8") as f: |
| test_helpers.WritePrettyJsonFile(test_bisect_state, f) |
| |
| self.assertEqual( |
| llvm_bisection.LoadStatusFile(temp_json_file, start, end), |
| test_bisect_state, |
| ) |
| |
| def testLoadStatusFilePassedWithoutExistingFile(self): |
| start = 200 |
| end = 250 |
| |
| expected_bisect_state = {"start": start, "end": end, "jobs": []} |
| |
| last_tested = "/abs/path/to/file_that_does_not_exist.json" |
| |
| self.assertEqual( |
| llvm_bisection.LoadStatusFile(last_tested, start, end), |
| expected_bisect_state, |
| ) |
| |
| @mock.patch.object(modify_a_tryjob, "AddTryjob") |
| def testBisectPassed(self, mock_add_tryjob): |
| git_hash_list = ["a123testhash1", "a123testhash2", "a123testhash3"] |
| revisions_list = [102, 104, 106] |
| |
| # Simulate behavior of `AddTryjob()` when successfully launched a |
| # tryjob for the updated packages. |
| @test_helpers.CallCountsToMockFunctions |
| def MockAddTryjob( |
| call_count, |
| _packages, |
| _git_hash, |
| _revision, |
| _chroot_path, |
| _extra_cls, |
| _options, |
| _builder, |
| _svn_revision, |
| ): |
| if call_count < 2: |
| return {"rev": revisions_list[call_count], "status": "pending"} |
| |
| # Simulate an exception happened along the way when updating the |
| # packages' `LLVM_NEXT_HASH`. |
| if call_count == 2: |
| raise ValueError("Unable to launch tryjob") |
| |
| assert False, "Called `AddTryjob()` more than expected." |
| |
| # Use the test function to simulate `AddTryjob()`. |
| mock_add_tryjob.side_effect = MockAddTryjob |
| |
| start = 100 |
| end = 110 |
| |
| bisection_contents = {"start": start, "end": end, "jobs": []} |
| |
| args_output = test_helpers.ArgsOutputTest() |
| |
| packages = ["sys-devel/llvm"] |
| |
| # Create a temporary .JSON file to simulate a status file for bisection. |
| with test_helpers.CreateTemporaryJsonFile() as temp_json_file: |
| with open(temp_json_file, "w", encoding="utf-8") as f: |
| test_helpers.WritePrettyJsonFile(bisection_contents, f) |
| |
| # Verify that the status file is updated when an exception happened |
| # when attempting to launch a revision (i.e. progress is not lost). |
| with self.assertRaises(ValueError) as err: |
| llvm_bisection.Bisect( |
| revisions_list, |
| git_hash_list, |
| bisection_contents, |
| temp_json_file, |
| packages, |
| args_output.chromeos_path, |
| args_output.extra_change_lists, |
| args_output.options, |
| args_output.builders, |
| ) |
| |
| expected_bisection_contents = { |
| "start": start, |
| "end": end, |
| "jobs": [ |
| {"rev": revisions_list[0], "status": "pending"}, |
| {"rev": revisions_list[1], "status": "pending"}, |
| ], |
| } |
| |
| # Verify that the launched tryjobs were added to the status file |
| # when an exception happened. |
| with open(temp_json_file, encoding="utf-8") as f: |
| json_contents = json.load(f) |
| |
| self.assertEqual(json_contents, expected_bisection_contents) |
| |
| self.assertEqual(str(err.exception), "Unable to launch tryjob") |
| |
| self.assertEqual(mock_add_tryjob.call_count, 3) |
| |
| @mock.patch.object(subprocess, "check_output", return_value=None) |
| @mock.patch.object( |
| get_llvm_hash.LLVMHash, "GetLLVMHash", return_value="a123testhash4" |
| ) |
| @mock.patch.object(llvm_bisection, "GetCommitsBetween") |
| @mock.patch.object(llvm_bisection, "GetRemainingRange") |
| @mock.patch.object(llvm_bisection, "LoadStatusFile") |
| @mock.patch.object(chroot, "VerifyChromeOSRoot") |
| @mock.patch.object(chroot, "VerifyOutsideChroot", return_value=True) |
| def testMainPassed( |
| self, |
| mock_outside_chroot, |
| mock_chromeos_root, |
| mock_load_status_file, |
| mock_get_range, |
| mock_get_revision_and_hash_list, |
| _mock_get_bad_llvm_hash, |
| mock_abandon_cl, |
| ): |
| start = 500 |
| end = 502 |
| cl = 1 |
| |
| bisect_state = { |
| "start": start, |
| "end": end, |
| "jobs": [{"rev": 501, "status": "bad", "cl": cl}], |
| } |
| |
| skip_revisions = {501} |
| pending_revisions = {} |
| |
| mock_load_status_file.return_value = bisect_state |
| |
| mock_get_range.return_value = ( |
| start, |
| end, |
| pending_revisions, |
| skip_revisions, |
| ) |
| |
| mock_get_revision_and_hash_list.return_value = [], [] |
| |
| args_output = test_helpers.ArgsOutputTest() |
| args_output.start_rev = start |
| args_output.end_rev = end |
| args_output.parallel = 3 |
| args_output.src_path = None |
| args_output.chromeos_path = "somepath" |
| args_output.cleanup = True |
| |
| self.assertEqual( |
| llvm_bisection.main(args_output), |
| llvm_bisection.BisectionExitStatus.BISECTION_COMPLETE.value, |
| ) |
| |
| mock_chromeos_root.assert_called_once() |
| |
| mock_outside_chroot.assert_called_once() |
| |
| mock_load_status_file.assert_called_once() |
| |
| mock_get_range.assert_called_once() |
| |
| mock_get_revision_and_hash_list.assert_called_once() |
| |
| mock_abandon_cl.assert_called_once() |
| self.assertEqual( |
| mock_abandon_cl.call_args, |
| mock.call( |
| [ |
| os.path.join( |
| args_output.chromeos_path, "chromite/bin/gerrit" |
| ), |
| "abandon", |
| str(cl), |
| ], |
| stderr=subprocess.STDOUT, |
| encoding="utf-8", |
| ), |
| ) |
| |
| @mock.patch.object(llvm_bisection, "LoadStatusFile") |
| @mock.patch.object(chroot, "VerifyChromeOSRoot") |
| @mock.patch.object(chroot, "VerifyOutsideChroot", return_value=True) |
| def testMainFailedWithInvalidRange( |
| self, mock_chromeos_root, mock_outside_chroot, mock_load_status_file |
| ): |
| start = 500 |
| end = 502 |
| |
| bisect_state = { |
| "start": start - 1, |
| "end": end, |
| } |
| |
| mock_load_status_file.return_value = bisect_state |
| |
| args_output = test_helpers.ArgsOutputTest() |
| args_output.start_rev = start |
| args_output.end_rev = end |
| args_output.parallel = 3 |
| args_output.src_path = None |
| |
| with self.assertRaises(ValueError) as err: |
| llvm_bisection.main(args_output) |
| |
| error_message = ( |
| f"The start {start} or the end {end} version provided is " |
| f'different than "start" {bisect_state["start"]} or "end" ' |
| f'{bisect_state["end"]} in the .JSON file' |
| ) |
| |
| self.assertEqual(str(err.exception), error_message) |
| |
| mock_chromeos_root.assert_called_once() |
| |
| mock_outside_chroot.assert_called_once() |
| |
| mock_load_status_file.assert_called_once() |
| |
| @mock.patch.object(llvm_bisection, "GetCommitsBetween") |
| @mock.patch.object(llvm_bisection, "GetRemainingRange") |
| @mock.patch.object(llvm_bisection, "LoadStatusFile") |
| @mock.patch.object(chroot, "VerifyChromeOSRoot") |
| @mock.patch.object(chroot, "VerifyOutsideChroot", return_value=True) |
| def testMainFailedWithPendingBuilds( |
| self, |
| mock_chromeos_root, |
| mock_outside_chroot, |
| mock_load_status_file, |
| mock_get_range, |
| mock_get_revision_and_hash_list, |
| ): |
| start = 500 |
| end = 502 |
| rev = 501 |
| |
| bisect_state = { |
| "start": start, |
| "end": end, |
| "jobs": [{"rev": rev, "status": "pending"}], |
| } |
| |
| skip_revisions = {} |
| pending_revisions = {rev} |
| |
| mock_load_status_file.return_value = bisect_state |
| |
| mock_get_range.return_value = ( |
| start, |
| end, |
| pending_revisions, |
| skip_revisions, |
| ) |
| |
| mock_get_revision_and_hash_list.return_value = [], [] |
| |
| args_output = test_helpers.ArgsOutputTest() |
| args_output.start_rev = start |
| args_output.end_rev = end |
| args_output.parallel = 3 |
| args_output.src_path = None |
| |
| with self.assertRaises(ValueError) as err: |
| llvm_bisection.main(args_output) |
| |
| error_message = ( |
| f"No revisions between start {start} and end {end} to " |
| "create tryjobs\nThe following tryjobs are pending:\n" |
| f"{rev}\n" |
| ) |
| |
| self.assertEqual(str(err.exception), error_message) |
| |
| mock_chromeos_root.assert_called_once() |
| |
| mock_outside_chroot.assert_called_once() |
| |
| mock_load_status_file.assert_called_once() |
| |
| mock_get_range.assert_called_once() |
| |
| mock_get_revision_and_hash_list.assert_called_once() |
| |
| @mock.patch.object(llvm_bisection, "GetCommitsBetween") |
| @mock.patch.object(llvm_bisection, "GetRemainingRange") |
| @mock.patch.object(llvm_bisection, "LoadStatusFile") |
| @mock.patch.object(chroot, "VerifyChromeOSRoot") |
| @mock.patch.object(chroot, "VerifyOutsideChroot", return_value=True) |
| def testMainFailedWithDuplicateBuilds( |
| self, |
| mock_outside_chroot, |
| mock_chromeos_root, |
| mock_load_status_file, |
| mock_get_range, |
| mock_get_revision_and_hash_list, |
| ): |
| start = 500 |
| end = 502 |
| rev = 501 |
| git_hash = "a123testhash1" |
| |
| bisect_state = { |
| "start": start, |
| "end": end, |
| "jobs": [{"rev": rev, "status": "pending"}], |
| } |
| |
| skip_revisions = {} |
| pending_revisions = {rev} |
| |
| mock_load_status_file.return_value = bisect_state |
| |
| mock_get_range.return_value = ( |
| start, |
| end, |
| pending_revisions, |
| skip_revisions, |
| ) |
| |
| mock_get_revision_and_hash_list.return_value = [rev], [git_hash] |
| |
| args_output = test_helpers.ArgsOutputTest() |
| args_output.start_rev = start |
| args_output.end_rev = end |
| args_output.parallel = 3 |
| args_output.src_path = None |
| |
| with self.assertRaises(ValueError) as err: |
| llvm_bisection.main(args_output) |
| |
| error_message = 'Revision %d exists already in "jobs"' % rev |
| self.assertEqual(str(err.exception), error_message) |
| |
| mock_chromeos_root.assert_called_once() |
| |
| mock_outside_chroot.assert_called_once() |
| |
| mock_load_status_file.assert_called_once() |
| |
| mock_get_range.assert_called_once() |
| |
| mock_get_revision_and_hash_list.assert_called_once() |
| |
| @mock.patch.object(subprocess, "check_output", return_value=None) |
| @mock.patch.object( |
| get_llvm_hash.LLVMHash, "GetLLVMHash", return_value="a123testhash4" |
| ) |
| @mock.patch.object(llvm_bisection, "GetCommitsBetween") |
| @mock.patch.object(llvm_bisection, "GetRemainingRange") |
| @mock.patch.object(llvm_bisection, "LoadStatusFile") |
| @mock.patch.object(chroot, "VerifyChromeOSRoot") |
| @mock.patch.object(chroot, "VerifyOutsideChroot", return_value=True) |
| def testMainFailedToAbandonCL( |
| self, |
| mock_outside_chroot, |
| mock_chromeos_root, |
| mock_load_status_file, |
| mock_get_range, |
| mock_get_revision_and_hash_list, |
| _mock_get_bad_llvm_hash, |
| mock_abandon_cl, |
| ): |
| start = 500 |
| end = 502 |
| |
| bisect_state = { |
| "start": start, |
| "end": end, |
| "jobs": [{"rev": 501, "status": "bad", "cl": 0}], |
| } |
| |
| skip_revisions = {501} |
| pending_revisions = {} |
| |
| mock_load_status_file.return_value = bisect_state |
| |
| mock_get_range.return_value = ( |
| start, |
| end, |
| pending_revisions, |
| skip_revisions, |
| ) |
| |
| mock_get_revision_and_hash_list.return_value = ([], []) |
| |
| error_message = "Error message." |
| mock_abandon_cl.side_effect = subprocess.CalledProcessError( |
| returncode=1, cmd=[], output=error_message |
| ) |
| |
| args_output = test_helpers.ArgsOutputTest() |
| args_output.start_rev = start |
| args_output.end_rev = end |
| args_output.parallel = 3 |
| args_output.src_path = None |
| args_output.cleanup = True |
| |
| with self.assertRaises(subprocess.CalledProcessError) as err: |
| llvm_bisection.main(args_output) |
| |
| self.assertEqual(err.exception.output, error_message) |
| |
| mock_chromeos_root.assert_called_once() |
| |
| mock_outside_chroot.assert_called_once() |
| |
| mock_load_status_file.assert_called_once() |
| |
| mock_get_range.assert_called_once() |
| |
| |
| if __name__ == "__main__": |
| unittest.main() |