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