• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env vpython3
2# Copyright 2020 The Chromium Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import itertools
7import tempfile
8from typing import Iterable, Set
9import unittest
10from unittest import mock
11
12import six
13
14from pyfakefs import fake_filesystem_unittest
15
16from unexpected_passes_common import data_types
17from unexpected_passes_common import result_output
18from unexpected_passes_common import unittest_utils as uu
19
20from blinkpy.w3c import buganizer
21
22# Protected access is allowed for unittests.
23# pylint: disable=protected-access
24
25def CreateTextOutputPermutations(text: str, inputs: Iterable[str]) -> Set[str]:
26  """Creates permutations of |text| filled with the contents of |inputs|.
27
28  Some output ordering is not guaranteed, so this acts as a way to generate
29  all possible outputs instead of manually listing them.
30
31  Args:
32    text: A string containing a single string field to format.
33    inputs: An iterable of strings to permute.
34
35  Returns:
36    A set of unique permutations of |text| filled with |inputs|. E.g. if |text|
37    is '1%s2' and |inputs| is ['a', 'b'], the return value will be
38    set(['1ab2', '1ba2']).
39  """
40  permutations = set()
41  for p in itertools.permutations(inputs):
42    permutations.add(text % ''.join(p))
43  return permutations
44
45
46class ConvertUnmatchedResultsToStringDictUnittest(unittest.TestCase):
47  def testEmptyResults(self) -> None:
48    """Tests that providing empty results is a no-op."""
49    self.assertEqual(result_output._ConvertUnmatchedResultsToStringDict({}), {})
50
51  def testMinimalData(self) -> None:
52    """Tests that everything functions when minimal data is provided."""
53    unmatched_results = {
54        'builder': [
55            data_types.Result('foo', [], 'Failure', 'step', 'build_id'),
56        ],
57    }
58    expected_output = {
59        'foo': {
60            'builder': {
61                'step': [
62                    'Got "Failure" on http://ci.chromium.org/b/build_id with '
63                    'tags []',
64                ],
65            },
66        },
67    }
68    output = result_output._ConvertUnmatchedResultsToStringDict(
69        unmatched_results)
70    self.assertEqual(output, expected_output)
71
72  def testRegularData(self) -> None:
73    """Tests that everything functions when regular data is provided."""
74    unmatched_results = {
75        'builder': [
76            data_types.Result('foo', ['win', 'intel'], 'Failure', 'step_name',
77                              'build_id')
78        ],
79    }
80    # TODO(crbug.com/40177248): Hard-code the tag string once only Python 3 is
81    # supported.
82    expected_output = {
83        'foo': {
84            'builder': {
85                'step_name': [
86                    'Got "Failure" on http://ci.chromium.org/b/build_id with '
87                    'tags [%s]' % ' '.join(set(['win', 'intel'])),
88                ]
89            }
90        }
91    }
92    output = result_output._ConvertUnmatchedResultsToStringDict(
93        unmatched_results)
94    self.assertEqual(output, expected_output)
95
96
97class ConvertTestExpectationMapToStringDictUnittest(unittest.TestCase):
98  def testEmptyMap(self) -> None:
99    """Tests that providing an empty map is a no-op."""
100    self.assertEqual(
101        result_output._ConvertTestExpectationMapToStringDict(
102            data_types.TestExpectationMap()), {})
103
104  def testSemiStaleMap(self) -> None:
105    """Tests that everything functions when regular data is provided."""
106    expectation_map = data_types.TestExpectationMap({
107        'expectation_file':
108        data_types.ExpectationBuilderMap({
109            data_types.Expectation('foo/test', ['win', 'intel'], [
110                                       'RetryOnFailure'
111                                   ]):
112            data_types.BuilderStepMap({
113                'builder':
114                data_types.StepBuildStatsMap({
115                    'all_pass':
116                    uu.CreateStatsWithPassFails(2, 0),
117                    'all_fail':
118                    uu.CreateStatsWithPassFails(0, 2),
119                    'some_pass':
120                    uu.CreateStatsWithPassFails(1, 1),
121                }),
122            }),
123            data_types.Expectation('foo/test', ['linux', 'intel'], [
124                                       'RetryOnFailure'
125                                   ]):
126            data_types.BuilderStepMap({
127                'builder':
128                data_types.StepBuildStatsMap({
129                    'all_pass':
130                    uu.CreateStatsWithPassFails(2, 0),
131                }),
132            }),
133            data_types.Expectation('foo/test', ['mac', 'intel'], [
134                                       'RetryOnFailure'
135                                   ]):
136            data_types.BuilderStepMap({
137                'builder':
138                data_types.StepBuildStatsMap({
139                    'all_fail':
140                    uu.CreateStatsWithPassFails(0, 2),
141                }),
142            }),
143        }),
144    })
145    # TODO(crbug.com/40177248): Remove the Python 2 version once we are fully
146    # switched to Python 3.
147    if six.PY2:
148      expected_output = {
149          'expectation_file': {
150              'foo/test': {
151                  '"RetryOnFailure" expectation on "win intel"': {
152                      'builder': {
153                          'Fully passed in the following': [
154                              'all_pass (2/2 passed)',
155                          ],
156                          'Never passed in the following': [
157                              'all_fail (0/2 passed)',
158                          ],
159                          'Partially passed in the following': {
160                              'some_pass (1/2 passed)': [
161                                  data_types.BuildLinkFromBuildId('build_id0'),
162                              ],
163                          },
164                      },
165                  },
166                  '"RetryOnFailure" expectation on "intel linux"': {
167                      'builder': {
168                          'Fully passed in the following': [
169                              'all_pass (2/2 passed)',
170                          ],
171                      },
172                  },
173                  '"RetryOnFailure" expectation on "mac intel"': {
174                      'builder': {
175                          'Never passed in the following': [
176                              'all_fail (0/2 passed)',
177                          ],
178                      },
179                  },
180              },
181          },
182      }
183    else:
184      # Set ordering does not appear to be stable between test runs, as we can
185      # get either order of tags. So, generate them now instead of hard coding
186      # them.
187      linux_tags = ' '.join(set(['linux', 'intel']))
188      win_tags = ' '.join(set(['win', 'intel']))
189      mac_tags = ' '.join(set(['mac', 'intel']))
190      expected_output = {
191          'expectation_file': {
192              'foo/test': {
193                  '"RetryOnFailure" expectation on "%s"' % linux_tags: {
194                      'builder': {
195                          'Fully passed in the following': [
196                              'all_pass (2/2 passed)',
197                          ],
198                      },
199                  },
200                  '"RetryOnFailure" expectation on "%s"' % win_tags: {
201                      'builder': {
202                          'Fully passed in the following': [
203                              'all_pass (2/2 passed)',
204                          ],
205                          'Partially passed in the following': {
206                              'some_pass (1/2 passed)': [
207                                  data_types.BuildLinkFromBuildId('build_id0'),
208                              ],
209                          },
210                          'Never passed in the following': [
211                              'all_fail (0/2 passed)',
212                          ],
213                      },
214                  },
215                  '"RetryOnFailure" expectation on "%s"' % mac_tags: {
216                      'builder': {
217                          'Never passed in the following': [
218                              'all_fail (0/2 passed)',
219                          ],
220                      },
221                  },
222              },
223          },
224      }
225
226    str_dict = result_output._ConvertTestExpectationMapToStringDict(
227        expectation_map)
228    self.assertEqual(str_dict, expected_output)
229
230
231class ConvertUnusedExpectationsToStringDictUnittest(unittest.TestCase):
232  def testEmptyDict(self) -> None:
233    """Tests that nothing blows up when given an empty dict."""
234    self.assertEqual(result_output._ConvertUnusedExpectationsToStringDict({}),
235                     {})
236
237  def testBasic(self) -> None:
238    """Basic functionality test."""
239    unused = {
240        'foo_file': [
241            data_types.Expectation('foo/test', ['win', 'nvidia'],
242                                   ['Failure', 'Timeout']),
243        ],
244        'bar_file': [
245            data_types.Expectation('bar/test', ['win'], ['Failure']),
246            data_types.Expectation('bar/test2', ['win'], ['RetryOnFailure'])
247        ],
248    }
249    if six.PY2:
250      expected_output = {
251          'foo_file': [
252              '[ win nvidia ] foo/test [ Failure Timeout ]',
253          ],
254          'bar_file': [
255              '[ win ] bar/test [ Failure ]',
256              '[ win ] bar/test2 [ RetryOnFailure ]',
257          ],
258      }
259    else:
260      # Set ordering does not appear to be stable between test runs, as we can
261      # get either order of tags. So, generate them now instead of hard coding
262      # them.
263      tags = ' '.join(['nvidia', 'win'])
264      results = ' '.join(['Failure', 'Timeout'])
265      expected_output = {
266          'foo_file': [
267              '[ %s ] foo/test [ %s ]' % (tags, results),
268          ],
269          'bar_file': [
270              '[ win ] bar/test [ Failure ]',
271              '[ win ] bar/test2 [ RetryOnFailure ]',
272          ],
273      }
274    self.assertEqual(
275        result_output._ConvertUnusedExpectationsToStringDict(unused),
276        expected_output)
277
278
279class HtmlToFileUnittest(fake_filesystem_unittest.TestCase):
280  def setUp(self) -> None:
281    self.setUpPyfakefs()
282    self._file_handle = tempfile.NamedTemporaryFile(delete=False, mode='w')
283    self._filepath = self._file_handle.name
284
285  def testLinkifyString(self) -> None:
286    """Test for _LinkifyString()."""
287    self._file_handle.close()
288    s = 'a'
289    self.assertEqual(result_output._LinkifyString(s), 'a')
290    s = 'http://a'
291    self.assertEqual(result_output._LinkifyString(s),
292                     '<a href="http://a">http://a</a>')
293    s = 'link to http://a, click it'
294    self.assertEqual(result_output._LinkifyString(s),
295                     'link to <a href="http://a">http://a</a>, click it')
296
297  def testRecursiveHtmlToFileExpectationMap(self) -> None:
298    """Tests _RecursiveHtmlToFile() with an expectation map as input."""
299    expectation_map = {
300        'foo': {
301            '"RetryOnFailure" expectation on "win intel"': {
302                'builder': {
303                    'Fully passed in the following': [
304                        'all_pass (2/2)',
305                    ],
306                    'Never passed in the following': [
307                        'all_fail (0/2)',
308                    ],
309                    'Partially passed in the following': {
310                        'some_pass (1/2)': [
311                            data_types.BuildLinkFromBuildId('build_id0'),
312                        ],
313                    },
314                },
315            },
316        },
317    }
318    result_output._RecursiveHtmlToFile(expectation_map, self._file_handle)
319    self._file_handle.close()
320    # pylint: disable=line-too-long
321    # TODO(crbug.com/40177248): Remove the Python 2 version once we've fully
322    # switched to Python 3.
323    if six.PY2:
324      expected_output = """\
325<button type="button" class="collapsible_group">foo</button>
326<div class="content">
327  <button type="button" class="collapsible_group">"RetryOnFailure" expectation on "win intel"</button>
328  <div class="content">
329    <button type="button" class="collapsible_group">builder</button>
330    <div class="content">
331      <button type="button" class="collapsible_group">Never passed in the following</button>
332      <div class="content">
333        <p>all_fail (0/2)</p>
334      </div>
335      <button type="button" class="highlighted_collapsible_group">Fully passed in the following</button>
336      <div class="content">
337        <p>all_pass (2/2)</p>
338      </div>
339      <button type="button" class="collapsible_group">Partially passed in the following</button>
340      <div class="content">
341        <button type="button" class="collapsible_group">some_pass (1/2)</button>
342        <div class="content">
343          <p><a href="http://ci.chromium.org/b/build_id0">http://ci.chromium.org/b/build_id0</a></p>
344        </div>
345      </div>
346    </div>
347  </div>
348</div>
349"""
350    else:
351      expected_output = """\
352<button type="button" class="collapsible_group">foo</button>
353<div class="content">
354  <button type="button" class="collapsible_group">"RetryOnFailure" expectation on "win intel"</button>
355  <div class="content">
356    <button type="button" class="collapsible_group">builder</button>
357    <div class="content">
358      <button type="button" class="highlighted_collapsible_group">Fully passed in the following</button>
359      <div class="content">
360        <p>all_pass (2/2)</p>
361      </div>
362      <button type="button" class="collapsible_group">Never passed in the following</button>
363      <div class="content">
364        <p>all_fail (0/2)</p>
365      </div>
366      <button type="button" class="collapsible_group">Partially passed in the following</button>
367      <div class="content">
368        <button type="button" class="collapsible_group">some_pass (1/2)</button>
369        <div class="content">
370          <p><a href="http://ci.chromium.org/b/build_id0">http://ci.chromium.org/b/build_id0</a></p>
371        </div>
372      </div>
373    </div>
374  </div>
375</div>
376"""
377    # pylint: enable=line-too-long
378    expected_output = _Dedent(expected_output)
379    with open(self._filepath) as f:
380      self.assertEqual(f.read(), expected_output)
381
382  def testRecursiveHtmlToFileUnmatchedResults(self) -> None:
383    """Tests _RecursiveHtmlToFile() with unmatched results as input."""
384    unmatched_results = {
385        'foo': {
386            'builder': {
387                None: [
388                    'Expected "" on http://ci.chromium.org/b/build_id, got '
389                    '"Failure" with tags []',
390                ],
391                'step_name': [
392                    'Expected "Failure RetryOnFailure" on '
393                    'http://ci.chromium.org/b/build_id, got '
394                    '"Failure" with tags [win intel]',
395                ]
396            },
397        },
398    }
399    result_output._RecursiveHtmlToFile(unmatched_results, self._file_handle)
400    self._file_handle.close()
401    # pylint: disable=line-too-long
402    # Order is not guaranteed, so create permutations.
403    expected_template = """\
404<button type="button" class="collapsible_group">foo</button>
405<div class="content">
406  <button type="button" class="collapsible_group">builder</button>
407  <div class="content">
408    %s
409  </div>
410</div>
411"""
412    values = [
413        """\
414    <button type="button" class="collapsible_group">None</button>
415    <div class="content">
416      <p>Expected "" on <a href="http://ci.chromium.org/b/build_id">http://ci.chromium.org/b/build_id</a>, got "Failure" with tags []</p>
417    </div>
418""",
419        """\
420    <button type="button" class="collapsible_group">step_name</button>
421    <div class="content">
422      <p>Expected "Failure RetryOnFailure" on <a href="http://ci.chromium.org/b/build_id">http://ci.chromium.org/b/build_id</a>, got "Failure" with tags [win intel]</p>
423    </div>
424""",
425    ]
426    expected_output = CreateTextOutputPermutations(expected_template, values)
427    # pylint: enable=line-too-long
428    expected_output = [_Dedent(e) for e in expected_output]
429    with open(self._filepath) as f:
430      self.assertIn(f.read(), expected_output)
431
432
433class PrintToFileUnittest(fake_filesystem_unittest.TestCase):
434  def setUp(self) -> None:
435    self.setUpPyfakefs()
436    self._file_handle = tempfile.NamedTemporaryFile(delete=False, mode='w')
437    self._filepath = self._file_handle.name
438
439  def testRecursivePrintToFileExpectationMap(self) -> None:
440    """Tests RecursivePrintToFile() with an expectation map as input."""
441    expectation_map = {
442        'foo': {
443            '"RetryOnFailure" expectation on "win intel"': {
444                'builder': {
445                    'Fully passed in the following': [
446                        'all_pass (2/2)',
447                    ],
448                    'Never passed in the following': [
449                        'all_fail (0/2)',
450                    ],
451                    'Partially passed in the following': {
452                        'some_pass (1/2)': [
453                            data_types.BuildLinkFromBuildId('build_id0'),
454                        ],
455                    },
456                },
457            },
458        },
459    }
460    result_output.RecursivePrintToFile(expectation_map, 0, self._file_handle)
461    self._file_handle.close()
462
463    # TODO(crbug.com/40177248): Keep the Python 3 version once we are fully
464    # switched.
465    if six.PY2:
466      expected_output = """\
467foo
468  "RetryOnFailure" expectation on "win intel"
469    builder
470      Never passed in the following
471        all_fail (0/2)
472      Fully passed in the following
473        all_pass (2/2)
474      Partially passed in the following
475        some_pass (1/2)
476          http://ci.chromium.org/b/build_id0
477"""
478    else:
479      expected_output = """\
480foo
481  "RetryOnFailure" expectation on "win intel"
482    builder
483      Fully passed in the following
484        all_pass (2/2)
485      Never passed in the following
486        all_fail (0/2)
487      Partially passed in the following
488        some_pass (1/2)
489          http://ci.chromium.org/b/build_id0
490"""
491    with open(self._filepath) as f:
492      self.assertEqual(f.read(), expected_output)
493
494  def testRecursivePrintToFileUnmatchedResults(self) -> None:
495    """Tests RecursivePrintToFile() with unmatched results as input."""
496    unmatched_results = {
497        'foo': {
498            'builder': {
499                None: [
500                    'Expected "" on http://ci.chromium.org/b/build_id, got '
501                    '"Failure" with tags []',
502                ],
503                'step_name': [
504                    'Expected "Failure RetryOnFailure" on '
505                    'http://ci.chromium.org/b/build_id, got '
506                    '"Failure" with tags [win intel]',
507                ]
508            },
509        },
510    }
511    result_output.RecursivePrintToFile(unmatched_results, 0, self._file_handle)
512    self._file_handle.close()
513    # pylint: disable=line-too-long
514    # Order is not guaranteed, so create permutations.
515    expected_template = """\
516foo
517  builder%s
518"""
519    values = [
520        """
521    None
522      Expected "" on http://ci.chromium.org/b/build_id, got "Failure" with tags []\
523""",
524        """
525    step_name
526      Expected "Failure RetryOnFailure" on http://ci.chromium.org/b/build_id, got "Failure" with tags [win intel]\
527""",
528    ]
529    expected_output = CreateTextOutputPermutations(expected_template, values)
530    # pylint: enable=line-too-long
531    with open(self._filepath) as f:
532      self.assertIn(f.read(), expected_output)
533
534
535class OutputResultsUnittest(fake_filesystem_unittest.TestCase):
536  def setUp(self) -> None:
537    self.setUpPyfakefs()
538    self._file_handle = tempfile.NamedTemporaryFile(delete=False, mode='w')
539    self._filepath = self._file_handle.name
540
541  def testOutputResultsUnsupportedFormat(self) -> None:
542    """Tests that passing in an unsupported format is an error."""
543    with self.assertRaises(RuntimeError):
544      result_output.OutputResults(data_types.TestExpectationMap(),
545                                  data_types.TestExpectationMap(),
546                                  data_types.TestExpectationMap(), {}, {},
547                                  'asdf')
548
549  def testOutputResultsSmoketest(self) -> None:
550    """Test that nothing blows up when outputting."""
551    expectation_map = data_types.TestExpectationMap({
552        'foo':
553        data_types.ExpectationBuilderMap({
554            data_types.Expectation('foo', ['win', 'intel'], 'RetryOnFailure'):
555            data_types.BuilderStepMap({
556                'stale':
557                data_types.StepBuildStatsMap({
558                    'all_pass':
559                    uu.CreateStatsWithPassFails(2, 0),
560                }),
561            }),
562            data_types.Expectation('foo', ['linux'], 'Failure'):
563            data_types.BuilderStepMap({
564                'semi_stale':
565                data_types.StepBuildStatsMap({
566                    'all_pass':
567                    uu.CreateStatsWithPassFails(2, 0),
568                    'some_pass':
569                    uu.CreateStatsWithPassFails(1, 1),
570                    'none_pass':
571                    uu.CreateStatsWithPassFails(0, 2),
572                }),
573            }),
574            data_types.Expectation('foo', ['mac'], 'Failure'):
575            data_types.BuilderStepMap({
576                'active':
577                data_types.StepBuildStatsMap({
578                    'none_pass':
579                    uu.CreateStatsWithPassFails(0, 2),
580                }),
581            }),
582        }),
583    })
584    unmatched_results = {
585        'builder': [
586            data_types.Result('foo', ['win', 'intel'], 'Failure', 'step_name',
587                              'build_id'),
588        ],
589    }
590    unmatched_expectations = {
591        'foo_file': [
592            data_types.Expectation('foo', ['linux'], 'RetryOnFailure'),
593        ],
594    }
595
596    stale, semi_stale, active = expectation_map.SplitByStaleness()
597
598    result_output.OutputResults(stale, semi_stale, active, {}, {}, 'print',
599                                self._file_handle)
600    result_output.OutputResults(stale, semi_stale, active, unmatched_results,
601                                {}, 'print', self._file_handle)
602    result_output.OutputResults(stale, semi_stale, active, {},
603                                unmatched_expectations, 'print',
604                                self._file_handle)
605    result_output.OutputResults(stale, semi_stale, active, unmatched_results,
606                                unmatched_expectations, 'print',
607                                self._file_handle)
608
609    result_output.OutputResults(stale, semi_stale, active, {}, {}, 'html',
610                                self._file_handle)
611    result_output.OutputResults(stale, semi_stale, active, unmatched_results,
612                                {}, 'html', self._file_handle)
613    result_output.OutputResults(stale, semi_stale, active, {},
614                                unmatched_expectations, 'html',
615                                self._file_handle)
616    result_output.OutputResults(stale, semi_stale, active, unmatched_results,
617                                unmatched_expectations, 'html',
618                                self._file_handle)
619
620
621class OutputAffectedUrlsUnittest(fake_filesystem_unittest.TestCase):
622  def setUp(self) -> None:
623    self.setUpPyfakefs()
624    self._file_handle = tempfile.NamedTemporaryFile(delete=False, mode='w')
625    self._filepath = self._file_handle.name
626
627  def testOutput(self) -> None:
628    """Tests that the output is correct."""
629    urls = [
630        'https://crbug.com/1234',
631        'https://crbug.com/angleproject/1234',
632        'http://crbug.com/2345',
633        'crbug.com/3456',
634    ]
635    orphaned_urls = ['https://crbug.com/1234', 'crbug.com/3456']
636    result_output._OutputAffectedUrls(urls, orphaned_urls, self._file_handle)
637    self._file_handle.close()
638    with open(self._filepath) as f:
639      self.assertEqual(f.read(), ('Affected bugs: '
640                                  'https://crbug.com/1234 '
641                                  'https://crbug.com/angleproject/1234 '
642                                  'http://crbug.com/2345 '
643                                  'https://crbug.com/3456\n'
644                                  'Closable bugs: '
645                                  'https://crbug.com/1234 '
646                                  'https://crbug.com/3456\n'))
647
648
649class OutputUrlsForClDescriptionUnittest(fake_filesystem_unittest.TestCase):
650  def setUp(self) -> None:
651    self.setUpPyfakefs()
652    self._file_handle = tempfile.NamedTemporaryFile(delete=False, mode='w')
653    self._filepath = self._file_handle.name
654
655  def testSingleLine(self) -> None:
656    """Tests when all bugs can fit on a single line."""
657    urls = [
658        'crbug.com/1234',
659        'https://crbug.com/angleproject/2345',
660    ]
661    result_output._OutputUrlsForClDescription(urls, [], self._file_handle)
662    self._file_handle.close()
663    with open(self._filepath) as f:
664      self.assertEqual(f.read(), ('Affected bugs for CL description:\n'
665                                  'Bug: 1234, angleproject:2345\n'))
666
667  def testBugLimit(self) -> None:
668    """Tests that only a certain number of bugs are allowed per line."""
669    urls = [
670        'crbug.com/1',
671        'crbug.com/2',
672        'crbug.com/3',
673        'crbug.com/4',
674        'crbug.com/5',
675        'crbug.com/6',
676    ]
677    result_output._OutputUrlsForClDescription(urls, [], self._file_handle)
678    self._file_handle.close()
679    with open(self._filepath) as f:
680      self.assertEqual(f.read(), ('Affected bugs for CL description:\n'
681                                  'Bug: 1, 2, 3, 4, 5\n'
682                                  'Bug: 6\n'))
683
684  def testLengthLimit(self) -> None:
685    """Tests that only a certain number of characters are allowed per line."""
686    urls = [
687        'crbug.com/averylongprojectthatwillgooverthelinelength/1',
688        'crbug.com/averylongprojectthatwillgooverthelinelength/2',
689    ]
690    result_output._OutputUrlsForClDescription(urls, [], self._file_handle)
691    self._file_handle.close()
692    with open(self._filepath) as f:
693      self.assertEqual(f.read(),
694                       ('Affected bugs for CL description:\n'
695                        'Bug: averylongprojectthatwillgooverthelinelength:1\n'
696                        'Bug: averylongprojectthatwillgooverthelinelength:2\n'))
697
698    project_name = (result_output.MAX_CHARACTERS_PER_CL_LINE - len('Bug: ') -
699                    len(':1, 2')) * 'a'
700    urls = [
701        'crbug.com/%s/1' % project_name,
702        'crbug.com/2',
703    ]
704    with open(self._filepath, 'w') as f:
705      result_output._OutputUrlsForClDescription(urls, [], f)
706    with open(self._filepath) as f:
707      self.assertEqual(f.read(), ('Affected bugs for CL description:\n'
708                                  'Bug: 2, %s:1\n' % project_name))
709
710    project_name += 'a'
711    urls = [
712        'crbug.com/%s/1' % project_name,
713        'crbug.com/2',
714    ]
715    with open(self._filepath, 'w') as f:
716      result_output._OutputUrlsForClDescription(urls, [], f)
717    with open(self._filepath) as f:
718      self.assertEqual(f.read(), ('Affected bugs for CL description:\n'
719                                  'Bug: 2\nBug: %s:1\n' % project_name))
720
721  def testSingleBugOverLineLimit(self) -> None:
722    """Tests the behavior when a single bug by itself is over the line limit."""
723    project_name = result_output.MAX_CHARACTERS_PER_CL_LINE * 'a'
724    urls = [
725        'crbug.com/%s/1' % project_name,
726        'crbug.com/2',
727    ]
728    result_output._OutputUrlsForClDescription(urls, [], self._file_handle)
729    self._file_handle.close()
730    with open(self._filepath) as f:
731      self.assertEqual(f.read(), ('Affected bugs for CL description:\n'
732                                  'Bug: 2\n'
733                                  'Bug: %s:1\n' % project_name))
734
735  def testOrphanedBugs(self) -> None:
736    """Tests that orphaned bugs are output properly alongside affected ones."""
737    urls = [
738        'crbug.com/1',
739        'crbug.com/2',
740        'crbug.com/3',
741    ]
742    orphaned_urls = ['crbug.com/2']
743    result_output._OutputUrlsForClDescription(urls, orphaned_urls,
744                                              self._file_handle)
745    self._file_handle.close()
746    with open(self._filepath) as f:
747      self.assertEqual(f.read(), ('Affected bugs for CL description:\n'
748                                  'Bug: 1, 3\n'
749                                  'Fixed: 2\n'))
750
751  def testOnlyOrphanedBugs(self) -> None:
752    """Tests output when all affected bugs are orphaned bugs."""
753    urls = [
754        'crbug.com/1',
755        'crbug.com/2',
756    ]
757    orphaned_urls = [
758        'crbug.com/1',
759        'crbug.com/2',
760    ]
761    result_output._OutputUrlsForClDescription(urls, orphaned_urls,
762                                              self._file_handle)
763    self._file_handle.close()
764    with open(self._filepath) as f:
765      self.assertEqual(f.read(), ('Affected bugs for CL description:\n'
766                                  'Fixed: 1, 2\n'))
767
768  def testNoAutoCloseBugs(self):
769    """Tests behavior when not auto closing bugs."""
770    urls = [
771        'crbug.com/0',
772        'crbug.com/1',
773    ]
774    orphaned_urls = [
775        'crbug.com/0',
776    ]
777    mock_buganizer = MockBuganizerClient()
778    with mock.patch.object(result_output,
779                           '_GetBuganizerClient',
780                           return_value=mock_buganizer):
781      result_output._OutputUrlsForClDescription(urls,
782                                                orphaned_urls,
783                                                self._file_handle,
784                                                auto_close_bugs=False)
785    self._file_handle.close()
786    with open(self._filepath) as f:
787      self.assertEqual(f.read(), ('Affected bugs for CL description:\n'
788                                  'Bug: 1\n'
789                                  'Bug: 0\n'))
790    mock_buganizer.NewComment.assert_called_once_with(
791        'crbug.com/0', result_output.BUGANIZER_COMMENT)
792
793
794class MockBuganizerClient:
795
796  def __init__(self):
797    self.comment_list = []
798    self.NewComment = mock.Mock()
799
800  def GetIssueComments(self, _) -> list:
801    return self.comment_list
802
803
804class PostCommentsToOrphanedBugsUnittest(unittest.TestCase):
805
806  def setUp(self):
807    self._buganizer_client = MockBuganizerClient()
808    self._buganizer_patcher = mock.patch.object(
809        result_output,
810        '_GetBuganizerClient',
811        return_value=self._buganizer_client)
812    self._buganizer_patcher.start()
813    self.addCleanup(self._buganizer_patcher.stop)
814
815  def testBasic(self):
816    """Tests the basic/happy path scenario."""
817    self._buganizer_client.comment_list.append({'comment': 'Not matching'})
818    result_output._PostCommentsToOrphanedBugs(
819        ['crbug.com/0', 'crbug.com/angleproject/0'])
820    self.assertEqual(self._buganizer_client.NewComment.call_count, 2)
821    self._buganizer_client.NewComment.assert_any_call(
822        'crbug.com/0', result_output.BUGANIZER_COMMENT)
823    self._buganizer_client.NewComment.assert_any_call(
824        'crbug.com/angleproject/0', result_output.BUGANIZER_COMMENT)
825
826  def testNoDuplicateComments(self):
827    """Tests that duplicate comments are not posted on bugs."""
828    self._buganizer_client.comment_list.append(
829        {'comment': result_output.BUGANIZER_COMMENT})
830    result_output._PostCommentsToOrphanedBugs(
831        ['crbug.com/0', 'crbug.com/angleproject/0'])
832    self._buganizer_client.NewComment.assert_not_called()
833
834  def testInvalidBugUrl(self):
835    """Tests behavior when a non-crbug URL is provided."""
836    with mock.patch.object(self._buganizer_client,
837                           'GetIssueComments',
838                           side_effect=buganizer.BuganizerError):
839      with self.assertLogs(level='WARNING') as log_manager:
840        result_output._PostCommentsToOrphanedBugs(['somesite.com/0'])
841        for message in log_manager.output:
842          if 'Could not fetch or add comments for somesite.com/0' in message:
843            break
844        else:
845          self.fail('Did not find expected log message')
846    self._buganizer_client.NewComment.assert_not_called()
847
848  def testServiceDiscoveryError(self):
849    """Tests behavior when service discovery fails."""
850    with mock.patch.object(result_output,
851                           '_GetBuganizerClient',
852                           side_effect=buganizer.BuganizerError):
853      with self.assertLogs(level='ERROR') as log_manager:
854        result_output._PostCommentsToOrphanedBugs(['crbug.com/0'])
855        for message in log_manager.output:
856          if ('Encountered error when authenticating, cannot post '
857              'comments') in message:
858            break
859        else:
860          self.fail('Did not find expected log message')
861
862  def testGetIssueCommentsError(self):
863    """Tests behavior when GetIssueComments encounters an error."""
864    with mock.patch.object(self._buganizer_client,
865                           'GetIssueComments',
866                           side_effect=({
867                               'error': ':('
868                           }, [{
869                               'comment': 'Not matching'
870                           }])):
871      with self.assertLogs(level='ERROR') as log_manager:
872        result_output._PostCommentsToOrphanedBugs(
873            ['crbug.com/0', 'crbug.com/1'])
874        for message in log_manager.output:
875          if 'Failed to get comments from crbug.com/0: :(' in message:
876            break
877        else:
878          self.fail('Did not find expected log message')
879    self._buganizer_client.NewComment.assert_called_once_with(
880        'crbug.com/1', result_output.BUGANIZER_COMMENT)
881
882  def testGetIssueCommentsUnspecifiedError(self):
883    """Tests behavior when GetIssueComments encounters an unspecified error."""
884    with mock.patch.object(self._buganizer_client,
885                           'GetIssueComments',
886                           side_effect=({}, [{
887                               'comment': 'Not matching'
888                           }])):
889      with self.assertLogs(level='ERROR') as log_manager:
890        result_output._PostCommentsToOrphanedBugs(
891            ['crbug.com/0', 'crbug.com/1'])
892        for message in log_manager.output:
893          if ('Failed to get comments from crbug.com/0: error not provided'
894              in message):
895            break
896        else:
897          self.fail('Did not find expected log message')
898    self._buganizer_client.NewComment.assert_called_once_with(
899        'crbug.com/1', result_output.BUGANIZER_COMMENT)
900
901
902def _Dedent(s: str) -> str:
903  output = ''
904  for line in s.splitlines(True):
905    output += line.lstrip()
906  return output
907
908
909if __name__ == '__main__':
910  unittest.main(verbosity=2)
911