Home | History | Annotate | Download | only in dashboard
      1 # Copyright 2016 The Chromium Authors. All rights reserved.
      2 # Use of this source code is governed by a BSD-style license that can be
      3 # found in the LICENSE file.
      4 
      5 import copy
      6 import datetime
      7 import json
      8 import unittest
      9 
     10 import mock
     11 import webapp2
     12 import webtest
     13 
     14 from dashboard import bisect_fyi
     15 from dashboard import bisect_fyi_test
     16 from dashboard import layered_cache
     17 from dashboard import rietveld_service
     18 from dashboard import stored_object
     19 from dashboard import testing_common
     20 from dashboard import update_bug_with_results
     21 from dashboard import utils
     22 from dashboard.models import anomaly
     23 from dashboard.models import bug_data
     24 from dashboard.models import try_job
     25 
     26 _SAMPLE_BISECT_RESULTS_JSON = {
     27     'try_job_id': 6789,
     28     'bug_id': 4567,
     29     'status': 'completed',
     30     'bisect_bot': 'linux',
     31     'buildbot_log_url': '',
     32     'command': ('tools/perf/run_benchmark -v '
     33                 '--browser=release page_cycler.intl_ar_fa_he'),
     34     'metric': 'warm_times/page_load_time',
     35     'change': '',
     36     'score': 99.9,
     37     'good_revision': '306475',
     38     'bad_revision': '306478',
     39     'warnings': None,
     40     'abort_reason': None,
     41     'issue_url': 'https://issue_url/123456',
     42     'culprit_data': {
     43         'subject': 'subject',
     44         'author': 'author',
     45         'email': 'author (at] email.com',
     46         'cl_date': '1/2/2015',
     47         'commit_info': 'commit_info',
     48         'revisions_links': ['http://src.chromium.org/viewvc/chrome?view='
     49                             'revision&revision=20798'],
     50         'cl': '2a1781d64d'  # Should match config in bisect_fyi_test.py.
     51     },
     52     'revision_data': [
     53         {
     54             'depot_name': 'chromium',
     55             'deps_revision': 1234,
     56             'commit_hash': '1234abcdf',
     57             'mean_value': 70,
     58             'std_dev': 0,
     59             'values': [70, 70, 70],
     60             'result': 'good'
     61         }, {
     62             'depot_name': 'chromium',
     63             'deps_revision': 1235,
     64             'commit_hash': '1235abdcf',
     65             'mean_value': 80,
     66             'std_dev': 0,
     67             'values': [80, 80, 80],
     68             'result': 'bad'
     69         }
     70     ]
     71 }
     72 
     73 _REVISION_RESPONSE = """
     74 <html xmlns=....>
     75 <head><title>[chrome] Revision 207985</title></head><body><table>....
     76 <tr align="left">
     77 <th>Log Message:</th>
     78 <td> Message....</td>
     79 &gt; &gt; Review URL: <a href="https://codereview.chromium.org/81533002">\
     80 https://codereview.chromium.org/81533002</a>
     81 &gt;
     82 &gt; Review URL: <a href="https://codereview.chromium.org/96073002">\
     83 https://codereview.chromium.org/96073002</a>
     84 
     85 Review URL: <a href="https://codereview.chromium.org/17504006">\
     86 https://codereview.chromium.org/96363002</a></pre></td></tr></table>....</body>
     87 </html>
     88 """
     89 
     90 _PERF_TEST_CONFIG = """config = {
     91   'command': 'tools/perf/run_benchmark -v --browser=release\
     92 dromaeo.jslibstylejquery --profiler=trace',
     93   'good_revision': '215806',
     94   'bad_revision': '215828',
     95   'repeat_count': '1',
     96   'max_time_minutes': '120'
     97 }"""
     98 
     99 _ISSUE_RESPONSE = """
    100     {
    101       "description": "Issue Description.",
    102       "cc": [
    103               "chromium-reviews@chromium.org",
    104               "cc-bugs@chromium.org",
    105               "sullivan@google.com"
    106             ],
    107       "reviewers": [
    108                       "prasadv@google.com"
    109                    ],
    110       "owner_email": "sullivan@google.com",
    111       "private": false,
    112       "base_url": "svn://chrome-svn/chrome/trunk/src/",
    113       "owner":"sullivan",
    114       "subject":"Issue Subject",
    115       "created":"2013-06-20 22:23:27.227150",
    116       "patchsets":[1,21001,29001],
    117       "modified":"2013-06-22 00:59:38.530190",
    118       "closed":true,
    119       "commit":false,
    120       "issue":17504006
    121     }
    122 """
    123 
    124 
    125 def _MockFetch(url=None):
    126   url_to_response_map = {
    127       'http://src.chromium.org/viewvc/chrome?view=revision&revision=20798': [
    128           200, _REVISION_RESPONSE
    129       ],
    130       'http://src.chromium.org/viewvc/chrome?view=revision&revision=20799': [
    131           200, 'REVISION REQUEST FAILED!'
    132       ],
    133       'https://codereview.chromium.org/api/17504006': [
    134           200, json.dumps(json.loads(_ISSUE_RESPONSE))
    135       ],
    136   }
    137 
    138   if url not in url_to_response_map:
    139     assert False, 'Bad url %s' % url
    140 
    141   response_code = url_to_response_map[url][0]
    142   response = url_to_response_map[url][1]
    143   return testing_common.FakeResponseObject(response_code, response)
    144 
    145 
    146 # In this class, we patch apiclient.discovery.build so as to not make network
    147 # requests, which are normally made when the IssueTrackerService is initialized.
    148 @mock.patch('apiclient.discovery.build', mock.MagicMock())
    149 @mock.patch.object(utils, 'TickMonitoringCustomMetric', mock.MagicMock())
    150 class UpdateBugWithResultsTest(testing_common.TestCase):
    151 
    152   def setUp(self):
    153     super(UpdateBugWithResultsTest, self).setUp()
    154     app = webapp2.WSGIApplication([(
    155         '/update_bug_with_results',
    156         update_bug_with_results.UpdateBugWithResultsHandler)])
    157     self.testapp = webtest.TestApp(app)
    158     self._AddRietveldConfig()
    159 
    160   def _AddRietveldConfig(self):
    161     """Adds a RietveldConfig entity to the datastore.
    162 
    163     This is used in order to get the Rietveld URL when requests are made to the
    164     handler in te tests below. In the real datastore, the RietveldConfig entity
    165     would contain credentials.
    166     """
    167     rietveld_service.RietveldConfig(
    168         id='default_rietveld_config',
    169         client_email='sullivan (at] google.com',
    170         service_account_key='Fake Account Key',
    171         server_url='https://test-rietveld.appspot.com',
    172         internal_server_url='https://test-rietveld.appspot.com').put()
    173 
    174   def _AddTryJob(self, bug_id, status, bot, **kwargs):
    175     job = try_job.TryJob(bug_id=bug_id, status=status, bot=bot, **kwargs)
    176     job.put()
    177     bug_data.Bug(id=bug_id).put()
    178     return job
    179 
    180   @mock.patch(
    181       'google.appengine.api.urlfetch.fetch',
    182       mock.MagicMock(side_effect=_MockFetch))
    183   @mock.patch.object(
    184       update_bug_with_results.issue_tracker_service, 'IssueTrackerService',
    185       mock.MagicMock())
    186   def testGet(self):
    187     # Put succeeded, failed, staled, and not yet finished jobs in the
    188     # datastore.
    189     self._AddTryJob(11111, 'started', 'win_perf',
    190                     results_data=_SAMPLE_BISECT_RESULTS_JSON)
    191     staled_timestamp = (datetime.datetime.now() -
    192                         update_bug_with_results._STALE_TRYJOB_DELTA)
    193     self._AddTryJob(22222, 'started', 'win_perf',
    194                     last_ran_timestamp=staled_timestamp)
    195     self._AddTryJob(33333, 'failed', 'win_perf')
    196     self._AddTryJob(44444, 'started', 'win_perf')
    197 
    198     self.testapp.get('/update_bug_with_results')
    199     pending_jobs = try_job.TryJob.query().fetch()
    200     # Expects no jobs to be deleted.
    201     self.assertEqual(4, len(pending_jobs))
    202     self.assertEqual(11111, pending_jobs[0].bug_id)
    203     self.assertEqual('completed', pending_jobs[0].status)
    204     self.assertEqual(22222, pending_jobs[1].bug_id)
    205     self.assertEqual('staled', pending_jobs[1].status)
    206     self.assertEqual(33333, pending_jobs[2].bug_id)
    207     self.assertEqual('failed', pending_jobs[2].status)
    208     self.assertEqual(44444, pending_jobs[3].bug_id)
    209     self.assertEqual('started', pending_jobs[3].status)
    210 
    211   @mock.patch(
    212       'google.appengine.api.urlfetch.fetch',
    213       mock.MagicMock(side_effect=_MockFetch))
    214   @mock.patch.object(
    215       update_bug_with_results.issue_tracker_service, 'IssueTrackerService',
    216       mock.MagicMock())
    217   def testCreateTryJob_WithoutExistingBug(self):
    218     # Put succeeded job in the datastore.
    219     try_job.TryJob(
    220         bug_id=12345, status='started', bot='win_perf',
    221         results_data=_SAMPLE_BISECT_RESULTS_JSON).put()
    222 
    223     self.testapp.get('/update_bug_with_results')
    224     pending_jobs = try_job.TryJob.query().fetch()
    225 
    226     # Expects job to finish.
    227     self.assertEqual(1, len(pending_jobs))
    228     self.assertEqual(12345, pending_jobs[0].bug_id)
    229     self.assertEqual('completed', pending_jobs[0].status)
    230 
    231   @mock.patch.object(utils, 'ServiceAccountCredentials', mock.MagicMock())
    232   @mock.patch(
    233       'google.appengine.api.urlfetch.fetch',
    234       mock.MagicMock(side_effect=_MockFetch))
    235   @mock.patch.object(
    236       update_bug_with_results.issue_tracker_service.IssueTrackerService,
    237       'AddBugComment', mock.MagicMock(return_value=False))
    238   @mock.patch('logging.error')
    239   def testGet_FailsToUpdateBug_LogsErrorAndMovesOn(self, mock_logging_error):
    240     # Put a successful job and a failed job with partial results.
    241     # Note that AddBugComment is mocked to always returns false, which
    242     # simulates failing to post results to the issue tracker for all bugs.
    243     self._AddTryJob(12345, 'started', 'win_perf',
    244                     results_data=_SAMPLE_BISECT_RESULTS_JSON)
    245     self._AddTryJob(54321, 'started', 'win_perf',
    246                     results_data=_SAMPLE_BISECT_RESULTS_JSON)
    247     self.testapp.get('/update_bug_with_results')
    248 
    249     # Two errors should be logged.
    250     self.assertEqual(2, mock_logging_error.call_count)
    251 
    252     # The pending jobs should still be there.
    253     pending_jobs = try_job.TryJob.query().fetch()
    254     self.assertEqual(2, len(pending_jobs))
    255     self.assertEqual('started', pending_jobs[0].status)
    256     self.assertEqual('started', pending_jobs[1].status)
    257 
    258   @mock.patch(
    259       'google.appengine.api.urlfetch.fetch',
    260       mock.MagicMock(side_effect=_MockFetch))
    261   @mock.patch.object(
    262       update_bug_with_results.issue_tracker_service.IssueTrackerService,
    263       'AddBugComment')
    264   def testGet_BisectCulpritHasAuthor_AssignsAuthor(self, mock_update_bug):
    265     # When a bisect has a culprit for a perf regression,
    266     # author and reviewer of the CL should be cc'ed on issue update.
    267     self._AddTryJob(12345, 'started', 'win_perf',
    268                     results_data=_SAMPLE_BISECT_RESULTS_JSON)
    269 
    270     self.testapp.get('/update_bug_with_results')
    271     mock_update_bug.assert_called_once_with(
    272         mock.ANY, mock.ANY,
    273         cc_list=['author (at] email.com', 'prasadv (at] google.com'],
    274         merge_issue=None, labels=None, owner='author (at] email.com')
    275 
    276   @mock.patch(
    277       'google.appengine.api.urlfetch.fetch',
    278       mock.MagicMock(side_effect=_MockFetch))
    279   @mock.patch.object(
    280       update_bug_with_results.issue_tracker_service.IssueTrackerService,
    281       'AddBugComment')
    282   def testGet_FailedRevisionResponse(self, mock_add_bug):
    283     # When a Rietveld CL link fails to respond, only update CL owner in CC
    284     # list.
    285     sample_bisect_results = copy.deepcopy(_SAMPLE_BISECT_RESULTS_JSON)
    286     sample_bisect_results['revisions_links'] = [
    287         'http://src.chromium.org/viewvc/chrome?view=revision&revision=20799']
    288     self._AddTryJob(12345, 'started', 'win_perf',
    289                     results_data=sample_bisect_results)
    290 
    291     self.testapp.get('/update_bug_with_results')
    292     mock_add_bug.assert_called_once_with(mock.ANY,
    293                                          mock.ANY,
    294                                          cc_list=['author (at] email.com',
    295                                                   'prasadv (at] google.com'],
    296                                          merge_issue=None,
    297                                          labels=None,
    298                                          owner='author (at] email.com')
    299 
    300   @mock.patch(
    301       'google.appengine.api.urlfetch.fetch',
    302       mock.MagicMock(side_effect=_MockFetch))
    303   @mock.patch.object(
    304       update_bug_with_results.issue_tracker_service.IssueTrackerService,
    305       'AddBugComment', mock.MagicMock())
    306   def testGet_PositiveResult_StoresCommitHash(self):
    307     self._AddTryJob(12345, 'started', 'win_perf',
    308                     results_data=_SAMPLE_BISECT_RESULTS_JSON)
    309 
    310     self.testapp.get('/update_bug_with_results')
    311     self.assertEqual('12345',
    312                      layered_cache.GetExternal('commit_hash_2a1781d64d'))
    313 
    314   @mock.patch(
    315       'google.appengine.api.urlfetch.fetch',
    316       mock.MagicMock(side_effect=_MockFetch))
    317   @mock.patch.object(
    318       update_bug_with_results.issue_tracker_service.IssueTrackerService,
    319       'AddBugComment', mock.MagicMock())
    320   def testGet_NegativeResult_DoesNotStoreCommitHash(self):
    321     sample_bisect_results = copy.deepcopy(_SAMPLE_BISECT_RESULTS_JSON)
    322     sample_bisect_results['culprit_data'] = None
    323     self._AddTryJob(12345, 'started', 'win_perf',
    324                     results_data=sample_bisect_results)
    325     self.testapp.get('/update_bug_with_results')
    326 
    327     caches = layered_cache.CachedPickledString.query().fetch()
    328     # Only 1 cache for bisect stats.
    329     self.assertEqual(1, len(caches))
    330 
    331   def testMapAnomaliesToMergeIntoBug(self):
    332     # Add anomalies.
    333     test_keys = map(utils.TestKey, [
    334         'ChromiumGPU/linux-release/scrolling-benchmark/first_paint',
    335         'ChromiumGPU/linux-release/scrolling-benchmark/mean_frame_time'])
    336     anomaly.Anomaly(
    337         start_revision=9990, end_revision=9997, test=test_keys[0],
    338         median_before_anomaly=100, median_after_anomaly=200,
    339         sheriff=None, bug_id=12345).put()
    340     anomaly.Anomaly(
    341         start_revision=9990, end_revision=9996, test=test_keys[0],
    342         median_before_anomaly=100, median_after_anomaly=200,
    343         sheriff=None, bug_id=54321).put()
    344     # Map anomalies to base(dest_bug_id) bug.
    345     update_bug_with_results._MapAnomaliesToMergeIntoBug(
    346         dest_bug_id=12345, source_bug_id=54321)
    347     anomalies = anomaly.Anomaly.query(
    348         anomaly.Anomaly.bug_id == int(54321)).fetch()
    349     self.assertEqual(0, len(anomalies))
    350 
    351   @mock.patch(
    352       'google.appengine.api.urlfetch.fetch',
    353       mock.MagicMock(side_effect=_MockFetch))
    354   @mock.patch.object(
    355       update_bug_with_results.email_template,
    356       'GetPerfTryJobEmailReport', mock.MagicMock(return_value=None))
    357   def testSendPerfTryJobEmail_EmptyEmailReport_DontSendEmail(self):
    358     self._AddTryJob(12345, 'started', 'win_perf', job_type='perf-try',
    359                     results_data=_SAMPLE_BISECT_RESULTS_JSON)
    360     self.testapp.get('/update_bug_with_results')
    361     messages = self.mail_stub.get_sent_messages()
    362     self.assertEqual(0, len(messages))
    363 
    364   @mock.patch(
    365       'google.appengine.api.urlfetch.fetch',
    366       mock.MagicMock(side_effect=_MockFetch))
    367   @mock.patch.object(
    368       update_bug_with_results.issue_tracker_service.IssueTrackerService,
    369       'AddBugComment')
    370   def testGet_InternalOnlyTryJob_AddsInternalOnlyBugLabel(
    371       self, mock_update_bug):
    372     self._AddTryJob(12345, 'started', 'win_perf',
    373                     results_data=_SAMPLE_BISECT_RESULTS_JSON,
    374                     internal_only=True)
    375 
    376     self.testapp.get('/update_bug_with_results')
    377     mock_update_bug.assert_called_once_with(
    378         mock.ANY, mock.ANY,
    379         cc_list=mock.ANY,
    380         merge_issue=None, labels=['Restrict-View-Google'], owner=mock.ANY)
    381 
    382   @mock.patch(
    383       'google.appengine.api.urlfetch.fetch',
    384       mock.MagicMock(side_effect=_MockFetch))
    385   @mock.patch.object(
    386       update_bug_with_results.issue_tracker_service, 'IssueTrackerService',
    387       mock.MagicMock())
    388   def testFYI_Send_No_Email_On_Success(self):
    389     stored_object.Set(
    390         bisect_fyi._BISECT_FYI_CONFIGS_KEY,
    391         bisect_fyi_test.TEST_FYI_CONFIGS)
    392     test_config = bisect_fyi_test.TEST_FYI_CONFIGS['positive_culprit']
    393     bisect_config = test_config.get('bisect_config')
    394     self._AddTryJob(12345, 'started', 'win_perf',
    395                     results_data=_SAMPLE_BISECT_RESULTS_JSON,
    396                     internal_only=True,
    397                     config=utils.BisectConfigPythonString(bisect_config),
    398                     job_type='bisect-fyi',
    399                     job_name='positive_culprit',
    400                     email='chris (at] email.com')
    401 
    402     self.testapp.get('/update_bug_with_results')
    403     messages = self.mail_stub.get_sent_messages()
    404     self.assertEqual(0, len(messages))
    405 
    406   @mock.patch(
    407       'google.appengine.api.urlfetch.fetch',
    408       mock.MagicMock(side_effect=_MockFetch))
    409   @mock.patch.object(
    410       update_bug_with_results.bisect_fyi, 'IsBugUpdated',
    411       mock.MagicMock(return_value=True))
    412   @mock.patch.object(
    413       update_bug_with_results.issue_tracker_service, 'IssueTrackerService',
    414       mock.MagicMock())
    415   def testFYI_Failed_Job_SendEmail(self):
    416     stored_object.Set(
    417         bisect_fyi._BISECT_FYI_CONFIGS_KEY,
    418         bisect_fyi_test.TEST_FYI_CONFIGS)
    419     test_config = bisect_fyi_test.TEST_FYI_CONFIGS['positive_culprit']
    420     bisect_config = test_config.get('bisect_config')
    421     sample_bisect_results = copy.deepcopy(_SAMPLE_BISECT_RESULTS_JSON)
    422     sample_bisect_results['status'] = 'failed'
    423     self._AddTryJob(12345, 'started', 'win_perf',
    424                     results_data=sample_bisect_results,
    425                     internal_only=True,
    426                     config=utils.BisectConfigPythonString(bisect_config),
    427                     job_type='bisect-fyi',
    428                     job_name='positive_culprit',
    429                     email='chris (at] email.com')
    430 
    431     self.testapp.get('/update_bug_with_results')
    432     messages = self.mail_stub.get_sent_messages()
    433     self.assertEqual(1, len(messages))
    434 
    435   @mock.patch.object(
    436       update_bug_with_results.quick_logger.QuickLogger,
    437       'Log', mock.MagicMock(return_value='record_key_123'))
    438   @mock.patch('logging.error')
    439   def testUpdateQuickLog_WithJobResults_NoError(self, mock_logging_error):
    440     job = self._AddTryJob(111, 'started', 'win_perf',
    441                           results_data=_SAMPLE_BISECT_RESULTS_JSON)
    442     update_bug_with_results.UpdateQuickLog(job)
    443     self.assertEqual(0, mock_logging_error.call_count)
    444 
    445   @mock.patch('logging.error')
    446   @mock.patch('update_bug_with_results.quick_logger.QuickLogger.Log')
    447   def testUpdateQuickLog_NoResultsData_ReportsError(
    448       self, mock_log, mock_logging_error):
    449     job = self._AddTryJob(111, 'started', 'win_perf')
    450     update_bug_with_results.UpdateQuickLog(job)
    451     self.assertEqual(0, mock_log.call_count)
    452     mock_logging_error.assert_called_once_with(
    453         'Bisect report returns empty for job id %s, bug_id %s.', 1, 111)
    454 
    455   @mock.patch(
    456       'google.appengine.api.urlfetch.fetch',
    457       mock.MagicMock(side_effect=_MockFetch))
    458   @mock.patch.object(
    459       update_bug_with_results.issue_tracker_service.IssueTrackerService,
    460       'AddBugComment')
    461   def testGet_PostResult_WithoutBugEntity(
    462       self, mock_update_bug):
    463     job = try_job.TryJob(bug_id=12345, status='started', bot='win_perf',
    464                          results_data=_SAMPLE_BISECT_RESULTS_JSON)
    465     job.put()
    466     self.testapp.get('/update_bug_with_results')
    467     mock_update_bug.assert_called_once_with(
    468         12345, mock.ANY, cc_list=mock.ANY, merge_issue=mock.ANY,
    469         labels=mock.ANY, owner=mock.ANY)
    470 
    471 
    472 if __name__ == '__main__':
    473   unittest.main()
    474