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