| #!/usr/bin/env vpython3 |
| # Copyright 2023 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| import json |
| import subprocess |
| import unittest |
| import unittest.mock as mock |
| |
| from machine_times import get_machine_times |
| |
| from unexpected_passes_common import data_types |
| |
| # pylint: disable=protected-access |
| |
| |
| class EnsureBuildbucketAuthUnittest(unittest.TestCase): |
| def testValidAuth(self): # pylint: disable=no-self-use |
| """Tests behavior when bb auth is valid.""" |
| with mock.patch.object(get_machine_times.subprocess, 'check_call'): |
| get_machine_times._EnsureBuildbucketAuth() |
| |
| def testInvalidAuth(self): |
| """Tests behavior when bb auth is invalid.""" |
| def SideEffect(*args, **kwargs): |
| raise subprocess.CalledProcessError(1, []) |
| |
| with mock.patch.object(get_machine_times.subprocess, |
| 'check_call', |
| side_effect=SideEffect): |
| with self.assertRaisesRegex( |
| RuntimeError, 'You are not logged into bb - run `bb auth-login`'): |
| get_machine_times._EnsureBuildbucketAuth() |
| |
| |
| class GetTimesForBuilderUnittest(unittest.TestCase): |
| def testNoBuildbucketIds(self): |
| """Tests behavior when no Buildbucket IDs are found.""" |
| builder = data_types.BuilderEntry('builder', 'ci', False) |
| with mock.patch.object(get_machine_times, |
| '_GetBuildbucketIdsForBuilder', |
| return_value=[]): |
| with self.assertLogs(level='WARNING'): |
| retval = get_machine_times._GetTimesForBuilder((builder, 1)) |
| self.assertEqual(retval, {'chromium/ci/builder': {}}) |
| |
| def testBasic(self): |
| """Basic happy path test.""" |
| builder = data_types.BuilderEntry('builder', 'ci', False) |
| step_output = { |
| 'steps': [ |
| { |
| 'name': |
| 'first step', |
| 'summaryMarkdown': |
| ('Max pending time: 2s (shard #1) ' |
| '* [shard #0 (runtime (1s) + overhead (1s): 2s)]' |
| '* [shard #1 (runtime (2s) + overhead (2s): 4s)]'), |
| }, |
| { |
| 'name': |
| 'second step', |
| 'summaryMarkdown': |
| ('Max pending time: 4s (shard #1) ' |
| '* [shard #0 (runtime (3s) + overhead (3s): 6s)]' |
| '* [shard #1 (runtime (4s) + overhead (4s): 8s)]'), |
| }, |
| ], |
| } |
| expected_output = { |
| 'chromium/ci/builder': { |
| 'first step': [ |
| (1, 1), |
| (2, 2), |
| ], |
| 'second step': [ |
| (3, 3), |
| (4, 4), |
| ], |
| }, |
| } |
| with mock.patch.object(get_machine_times, |
| '_GetBuildbucketIdsForBuilder', |
| return_value=['1234']): |
| with mock.patch.object(get_machine_times, |
| '_GetStepOutputForBuild', |
| return_value=json.dumps(step_output)): |
| self.assertEqual(get_machine_times._GetTimesForBuilder((builder, 1)), |
| expected_output) |
| |
| |
| class GetBuildbucketIdsForBuilderUnittest(unittest.TestCase): |
| def testBasic(self): |
| """Basic happy path test.""" |
| builder = data_types.BuilderEntry('builder', 'ci', False) |
| mock_process = mock.Mock() |
| mock_process.stdout = '1\n2\n3' |
| with mock.patch.object(get_machine_times.subprocess, |
| 'run', |
| return_value=mock_process) as mock_run: |
| self.assertEqual( |
| get_machine_times._GetBuildbucketIdsForBuilder(builder, 3), |
| ['1', '2', '3']) |
| mock_run.assert_called_once_with( |
| ['bb', 'ls', '-id', '-3', '-status', 'ended', 'chromium/ci/builder'], |
| text=True, |
| check=True, |
| stdout=subprocess.PIPE) |
| |
| |
| class GetStepOutputForBuildUnittest(unittest.TestCase): |
| def testBasic(self): |
| """Basic happy path test.""" |
| mock_process = mock.Mock() |
| mock_process.stdout = 'stdout' |
| with mock.patch.object(get_machine_times.subprocess, |
| 'run', |
| return_value=mock_process) as mock_run: |
| self.assertEqual(get_machine_times._GetStepOutputForBuild('1234'), |
| 'stdout') |
| mock_run.assert_called_once_with(['bb', 'get', '-json', '-steps', '1234'], |
| text=True, |
| check=True, |
| stdout=subprocess.PIPE) |
| |
| |
| class GetShardTimesFromStepOutputUnittest(unittest.TestCase): |
| def testNonSummaryIgnored(self): |
| """Tests that steps without a summary are ignored.""" |
| step_output = { |
| 'steps': [ |
| { |
| 'name': 'builder cache|check if empty', |
| }, |
| ], |
| } |
| self.assertEqual( |
| get_machine_times._GetShardTimesFromStepOutput(json.dumps(step_output)), |
| {}) |
| |
| def testSummaryFiltering(self): |
| """Tests that only steps with certain summaries are used.""" |
| step_output = { |
| 'steps': [ |
| { |
| 'name': |
| 'bad step', |
| 'summaryMarkdown': |
| '* [shard #0 (runtime (1s) + overhead (1s): 2s)]', |
| }, |
| { |
| 'name': |
| 'Multi shard with pending time', |
| 'summaryMarkdown': |
| ('Max pending time: 38s (shard #5) ' |
| '* [shard #0 (runtime (2s) + overhead (2s): 4s)]'), |
| }, |
| { |
| 'name': |
| 'Single shard with pending time', |
| 'summaryMarkdown': |
| ('Pending time: 40s ' |
| '* [shard #0 (runtime (3s) + overhead (3s): 6s)]'), |
| }, |
| { |
| 'name': |
| 'Single shard with no pending time', |
| 'summaryMarkdown': |
| ('Shard runtime 4s ' |
| '* [shard #0 (runtime (4s) + overhead (4s): 8s)]'), |
| }, |
| ], |
| } |
| expected_output = { |
| 'Multi shard with pending time': [(2, 2)], |
| 'Single shard with pending time': [(3, 3)], |
| 'Single shard with no pending time': [(4, 4)], |
| } |
| self.assertEqual( |
| get_machine_times._GetShardTimesFromStepOutput(json.dumps(step_output)), |
| expected_output) |
| |
| def testPassingMatch(self): |
| """Tests that shard times can be extracted from passing shards.""" |
| step_output = { |
| 'steps': [ |
| { |
| 'name': |
| 'All passing', |
| 'summaryMarkdown': |
| ('Max pending time: 2s (shard #1) ' |
| '* [shard #0 (runtime (1s) + overhead (1s): 2s)]' |
| '* [shard #1 (runtime (2s) + overhead (2s): 4s)]'), |
| }, |
| ], |
| } |
| expected_output = { |
| 'All passing': [(1, 1), (2, 2)], |
| } |
| self.assertEqual( |
| get_machine_times._GetShardTimesFromStepOutput(json.dumps(step_output)), |
| expected_output) |
| |
| def testFailingMatch(self): |
| """Tests that shard times can be extracted from failing shards.""" |
| step_output = { |
| 'steps': [ |
| { |
| 'name': |
| 'All failing', |
| 'summaryMarkdown': ('Max pending time: 2s (shard #1)' |
| '* [shard #0 (failed) (1s)]' |
| '* [shard #1 (failed) (2s)]'), |
| }, |
| ], |
| } |
| expected_output = { |
| 'All failing': [(1, 0), (2, 0)], |
| } |
| self.assertEqual( |
| get_machine_times._GetShardTimesFromStepOutput(json.dumps(step_output)), |
| expected_output) |
| |
| def testTimeoutMatch(self): |
| """Tests that shard times can be extracted from timed out shards.""" |
| step_output = { |
| 'steps': [ |
| { |
| 'name': |
| 'All timeout', |
| 'summaryMarkdown': ('Max pending time: 2s (shard #1)' |
| '* [shard #0 timed out after 1s]' |
| '* [shard #1 timed out after 2s]'), |
| }, |
| ], |
| } |
| expected_output = { |
| 'All timeout': [(1, 0), (2, 0)], |
| } |
| self.assertEqual( |
| get_machine_times._GetShardTimesFromStepOutput(json.dumps(step_output)), |
| expected_output) |
| |
| def testSwarmingFailuresIgnored(self): |
| """Tests that internal swarming failures are silently ignored.""" |
| step_output = { |
| 'steps': [ |
| { |
| 'name': |
| 'All infra failure', |
| 'summaryMarkdown': |
| ('Max pending time: 2s (shard #1)' |
| '* [shard #0 had an internal swarming failure]' |
| '* [shard #1 had an internal swarming failure]'), |
| }, |
| ], |
| } |
| # assertNoLogs would be useful here, but is only available in Python 3.10 |
| # and above. |
| with mock.patch.object(get_machine_times.logging, |
| 'warning', |
| side_effect=RuntimeError): |
| self.assertEqual( |
| get_machine_times._GetShardTimesFromStepOutput( |
| json.dumps(step_output)), {}) |
| |
| def testNoDataReported(self): |
| """Tests that a failure to get shard runtimes is reported to the user.""" |
| step_output = { |
| 'steps': [ |
| { |
| 'name': 'Missing', |
| 'summaryMarkdown': 'Max pending time: 1s (shard #0)', |
| }, |
| ], |
| } |
| with self.assertLogs(level='WARNING'): |
| self.assertEqual( |
| get_machine_times._GetShardTimesFromStepOutput( |
| json.dumps(step_output)), {}) |
| |
| def testMixedShards(self): |
| """Tests shard time extraction with a mix of different shards.""" |
| step_output = { |
| 'steps': [ |
| { |
| 'name': |
| 'Mixed', |
| 'summaryMarkdown': |
| ('Max pending time: 3s (shard #2)' |
| '* [shard #0 (runtime (1s) + overhead (1s): 2s)]' |
| '* [shard #1 (failed) (2s)]' |
| '* [shard #2 timed out after 3s]'), |
| }, |
| ], |
| } |
| expected_output = { |
| 'Mixed': [(1, 1), (2, 0), (3, 0)], |
| } |
| self.assertEqual( |
| get_machine_times._GetShardTimesFromStepOutput(json.dumps(step_output)), |
| expected_output) |
| |
| def testDuplicateSteps(self): |
| """Tests that duplicate shards are not supported.""" |
| step_output = { |
| 'id': |
| 'build-id', |
| 'steps': [ |
| { |
| 'name': |
| 'I am the real one', |
| 'summaryMarkdown': |
| ('Max pending time: 2s (shard #1) ' |
| '* [shard #0 (runtime (1s) + overhead (1s): 2s)]' |
| '* [shard #1 (runtime (2s) + overhead (2s): 4s)]'), |
| }, |
| { |
| 'name': |
| 'I am the real one', |
| 'summaryMarkdown': |
| ('Max pending time: 2s (shard #1) ' |
| '* [shard #0 (runtime (1s) + overhead (1s): 2s)]' |
| '* [shard #1 (runtime (2s) + overhead (2s): 4s)]'), |
| }, |
| ], |
| } |
| with self.assertRaises(AssertionError): |
| get_machine_times._GetShardTimesFromStepOutput(json.dumps(step_output)) |
| |
| |
| class ConvertSummaryRuntimeToSecondsUnittest(unittest.TestCase): |
| def testMinutesAndSeconds(self): |
| """Tests conversion with minutes and seconds present.""" |
| self.assertEqual(get_machine_times._ConvertSummaryRuntimeToSeconds('1m 1s'), |
| 61) |
| |
| def testSecondsOnly(self): |
| """Tests conversion with only seconds present.""" |
| self.assertEqual(get_machine_times._ConvertSummaryRuntimeToSeconds('1s'), 1) |
| |
| |
| if __name__ == '__main__': |
| unittest.main(verbosity=2) |