github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/results-processor/processor_test.py (about)

     1  # Copyright 2019 The WPT Dashboard Project. 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 json
     6  import unittest
     7  from unittest.mock import call, patch
     8  
     9  from werkzeug.datastructures import MultiDict
    10  
    11  import test_util
    12  import wptreport
    13  from processor import Processor, process_report
    14  from test_server import AUTH_CREDENTIALS
    15  
    16  
    17  class ProcessorTest(unittest.TestCase):
    18      def fake_download(self, expected_path, response):
    19          def _download(path):
    20              if expected_path is None:
    21                  self.fail('Unexpected download:' + path)
    22              self.assertEqual(expected_path, path)
    23              return response
    24          return _download
    25  
    26      def test_known_extension(self):
    27          self.assertEqual(
    28              Processor.known_extension('https://wpt.fyi/test.json.gz'),
    29              '.json.gz')
    30          self.assertEqual(
    31              Processor.known_extension('https://wpt.fyi/test.txt.gz'),
    32              '.txt.gz')
    33          self.assertEqual(
    34              Processor.known_extension('https://wpt.fyi/test.json'), '.json')
    35          self.assertEqual(
    36              Processor.known_extension('https://wpt.fyi/test.txt'), '.txt')
    37          self.assertEqual(
    38              Processor.known_extension('artifact.zip'), '.zip')
    39  
    40      def test_download(self):
    41          with Processor() as p:
    42              p._download_gcs = self.fake_download(
    43                  'gs://wptd/foo/bar.json', '/fake/bar.json')
    44              p._download_http = self.fake_download(
    45                  'https://wpt.fyi/test.txt.gz', '/fake/test.txt.gz')
    46  
    47              p.download(
    48                  ['gs://wptd/foo/bar.json'],
    49                  ['https://wpt.fyi/test.txt.gz'],
    50                  None)
    51              self.assertListEqual(p.results, ['/fake/bar.json'])
    52              self.assertListEqual(p.screenshots, ['/fake/test.txt.gz'])
    53  
    54      def test_download_azure(self):
    55          with Processor() as p:
    56              p._download_gcs = self.fake_download(None, None)
    57              p._download_http = self.fake_download(
    58                  'https://wpt.fyi/artifact.zip', 'artifact_test.zip')
    59  
    60              p.download([], [], 'https://wpt.fyi/artifact.zip')
    61              self.assertEqual(len(p.results), 2)
    62              self.assertTrue(p.results[0].endswith(
    63                  '/artifact_test/wpt_report_1.json'))
    64              self.assertTrue(p.results[1].endswith(
    65                  '/artifact_test/wpt_report_2.json'))
    66              self.assertEqual(len(p.screenshots), 2)
    67              self.assertTrue(p.screenshots[0].endswith(
    68                  '/artifact_test/wpt_screenshot_1.txt'))
    69              self.assertTrue(p.screenshots[1].endswith(
    70                  '/artifact_test/wpt_screenshot_2.txt'))
    71  
    72      def test_download_azure_errors(self):
    73          with Processor() as p:
    74              p._download_gcs = self.fake_download(None, None)
    75              p._download_http = self.fake_download(
    76                  'https://wpt.fyi/artifact.zip', None)
    77  
    78              # Incorrect param combinations (both results & azure_url):
    79              with self.assertRaises(AssertionError):
    80                  p.download(['https://wpt.fyi/test.json.gz'],
    81                             [],
    82                             'https://wpt.fyi/artifact.zip')
    83  
    84              # Download failure: no exceptions should be raised.
    85              p.download([], [], 'https://wpt.fyi/artifact.zip')
    86              self.assertEqual(len(p.results), 0)
    87  
    88  
    89  class MockProcessorTest(unittest.TestCase):
    90      @patch('processor.Processor')
    91      def test_params_plumbing_success(self, MockProcessor):
    92          # Set up mock context manager to return self.
    93          mock = MockProcessor.return_value
    94          mock.__enter__.return_value = mock
    95          mock.check_existing_run.return_value = False
    96          mock.results = ['/tmp/wpt_report.json.gz']
    97          mock.raw_results_url = 'https://wpt.fyi/test/report.json'
    98          mock.results_url = 'https://wpt.fyi/test'
    99          mock.test_run_id = 654321
   100  
   101          # NOTE: if you need to change the following params, you probably also
   102          # want to change api/receiver/api.go.
   103          params = MultiDict({
   104              'uploader': 'blade-runner',
   105              'id': '654321',
   106              'callback_url': 'https://test.wpt.fyi/api',
   107              'labels': 'foo,bar',
   108              'results': 'https://wpt.fyi/wpt_report.json.gz',
   109              'browser_name': 'Chrome',
   110              'browser_version': '70',
   111              'os_name': 'Linux',
   112              'os_version': '5.0',
   113              'revision': '21917b36553562d21c14fe086756a57cbe8a381b',
   114          })
   115          process_report('12345', params)
   116          mock.assert_has_calls([
   117              call.update_status('654321', 'WPTFYI_PROCESSING', None,
   118                                 'https://test.wpt.fyi/api'),
   119              call.download(['https://wpt.fyi/wpt_report.json.gz'], [], None),
   120          ])
   121          mock.report.update_metadata.assert_called_once_with(
   122              revision='21917b36553562d21c14fe086756a57cbe8a381b',
   123              browser_name='Chrome', browser_version='70',
   124              os_name='Linux', os_version='5.0')
   125          mock.create_run.assert_called_once_with(
   126              '654321', 'foo,bar', 'blade-runner', 'https://test.wpt.fyi/api')
   127  
   128      @patch('processor.Processor')
   129      def test_params_plumbing_error(self, MockProcessor):
   130          # Set up mock context manager to return self.
   131          mock = MockProcessor.return_value
   132          mock.__enter__.return_value = mock
   133          mock.results = ['/tmp/wpt_report.json.gz']
   134          mock.load_report.side_effect = wptreport.InvalidJSONError
   135  
   136          params = MultiDict({
   137              'uploader': 'blade-runner',
   138              'id': '654321',
   139              'results': 'https://wpt.fyi/wpt_report.json.gz',
   140          })
   141          # Suppress print_exception.
   142          with patch('traceback.print_exception'):
   143              process_report('12345', params)
   144          mock.assert_has_calls([
   145              call.update_status('654321', 'WPTFYI_PROCESSING', None, None),
   146              call.download(['https://wpt.fyi/wpt_report.json.gz'], [], None),
   147              call.load_report(),
   148              call.update_status(
   149                  '654321', 'INVALID',
   150                  "Invalid JSON (['https://wpt.fyi/wpt_report.json.gz'])", None),
   151          ])
   152          mock.create_run.assert_not_called()
   153  
   154      @patch('processor.Processor')
   155      def test_params_plumbing_empty(self, MockProcessor):
   156          # Set up mock context manager to return self.
   157          mock = MockProcessor.return_value
   158          mock.__enter__.return_value = mock
   159          mock.results = []
   160  
   161          params = MultiDict({
   162              'uploader': 'blade-runner',
   163              'id': '654321',
   164          })
   165          with self.assertLogs():
   166              process_report('12345', params)
   167          mock.assert_has_calls([
   168              call.update_status('654321', 'WPTFYI_PROCESSING', None, None),
   169              call.download([], [], None),
   170              call.update_status('654321', 'EMPTY', None, None),
   171          ])
   172          mock.create_run.assert_not_called()
   173  
   174      @patch('processor.Processor')
   175      def test_params_plumbing_duplicate(self, MockProcessor):
   176          # Set up mock context manager to return self.
   177          mock = MockProcessor.return_value
   178          mock.__enter__.return_value = mock
   179          mock.check_existing_run.return_value = True
   180          mock.results = ['/tmp/wpt_report.json.gz']
   181          mock.raw_results_url = 'https://wpt.fyi/test/report.json'
   182  
   183          params = MultiDict({
   184              'uploader': 'blade-runner',
   185              'id': '654321',
   186              'results': 'https://wpt.fyi/wpt_report.json.gz',
   187          })
   188          with self.assertLogs():
   189              process_report('12345', params)
   190          mock.update_status.assert_has_calls([
   191              call('654321', 'WPTFYI_PROCESSING', None, None),
   192              call('654321', 'DUPLICATE', None, None),
   193          ])
   194          mock.create_run.assert_not_called()
   195  
   196  
   197  class ProcessorDownloadServerTest(unittest.TestCase):
   198      """This class tests behaviours of Processor related to downloading
   199      artifacts (e.g. JSON reports) from an external server. test_server is used
   200      to emulate the success and failure modes of an external server.
   201      """
   202      def setUp(self):
   203          self.server, self.url = test_util.start_server(False)
   204  
   205      def tearDown(self):
   206          self.server.terminate()
   207          self.server.wait()
   208  
   209      def test_download_single(self):
   210          with Processor() as p:
   211              # The endpoint returns "Hello, world!".
   212              path = p._download_single(self.url + '/download/test.txt')
   213              self.assertTrue(path.endswith('.txt'))
   214              with open(path, 'rb') as f:
   215                  self.assertEqual(f.read(), b'Hello, world!')
   216  
   217      def test_download(self):
   218          with Processor() as p:
   219              p.TIMEOUT_WAIT = 0.1  # to speed up tests
   220              url_404 = self.url + '/404'
   221              url_timeout = self.url + '/slow'
   222              with self.assertLogs() as lm:
   223                  p.download(
   224                      [self.url + '/download/test.txt', url_timeout],
   225                      [url_404],
   226                      None)
   227              self.assertEqual(len(p.results), 1)
   228              self.assertTrue(p.results[0].endswith('.txt'))
   229              self.assertEqual(len(p.screenshots), 0)
   230              self.assertListEqual(
   231                  lm.output,
   232                  ['ERROR:processor:Timed out fetching: ' + url_timeout,
   233                   'ERROR:processor:Failed to fetch (404): ' + url_404])
   234  
   235      def test_download_content_disposition(self):
   236          with Processor() as p:
   237              # The response of this endpoint sets Content-Disposition with
   238              # artifact_test.zip as the filename.
   239              path = p._download_single(self.url + '/download/attachment')
   240              self.assertTrue(path.endswith('.zip'))
   241  
   242  
   243  class ProcessorAPIServerTest(unittest.TestCase):
   244      """This class tests API calls from Processor to webapp (e.g.
   245      /api/results/create, /api/status). test_server is used to emulate webapp
   246      and verify credentials and payloads.
   247      """
   248      def setUp(self):
   249          self.server, self.url = test_util.start_server(True)
   250  
   251      def tearDown(self):
   252          if self.server.poll() is None:
   253              self.server.kill()
   254  
   255      def test_update_status(self):
   256          with Processor() as p:
   257              p._auth = AUTH_CREDENTIALS
   258              p.report.update_metadata(
   259                  browser_name='Chrome',
   260                  browser_version='70',
   261                  os_name='Linux',
   262                  os_version='5.0',
   263                  revision='21917b36553562d21c14fe086756a57cbe8a381b')
   264              p.update_status(
   265                  run_id='12345', stage='INVALID',
   266                  error='Sample error', callback_url=self.url)
   267          self.server.terminate()
   268          _, err = self.server.communicate()
   269          response = json.loads(err)
   270          self.assertDictEqual(response, {
   271              'id': 12345, 'stage': 'INVALID', 'error': 'Sample error',
   272              'browser_name': 'Chrome', 'browser_version': '70',
   273              'os_name': 'Linux', 'os_version': '5.0',
   274              'full_revision_hash': '21917b36553562d21c14fe086756a57cbe8a381b',
   275          })
   276  
   277      def test_create_run(self):
   278          api = self.url + '/api/results/create'
   279          with Processor() as p:
   280              p._auth = AUTH_CREDENTIALS
   281              p.report.update_metadata(
   282                  browser_name='chrome',
   283                  browser_version='70',
   284                  os_name='Linux',
   285                  revision='21917b36553562d21c14fe086756a57cbe8a381b')
   286              p.create_run('12345', '', 'blade-runner', callback_url=api)
   287              # p.test_run_id is set based on the response from the API, which in
   288              # turn is set according to the request. Hence this verifies that we
   289              # pass the run ID to the API correctly.
   290              self.assertEqual(p.test_run_id, 12345)
   291          self.server.terminate()
   292          # This is needed to close the stdio pipes.
   293          self.server.communicate()