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