George Burgess IV | 853d65b | 2020-02-25 13:13:15 -0800 | [diff] [blame] | 1 | # Copyright 2020 The Chromium OS Authors. All rights reserved. |
| 2 | # Use of this source code is governed by a BSD-style license that can be |
| 3 | # found in the LICENSE file. |
| 4 | |
| 5 | """Unit tests for tricium_clang_tidy.py.""" |
| 6 | |
| 7 | import io |
| 8 | import json |
| 9 | import multiprocessing |
| 10 | import os |
| 11 | from pathlib import Path |
| 12 | import subprocess |
George Burgess IV | 853d65b | 2020-02-25 13:13:15 -0800 | [diff] [blame] | 13 | import tempfile |
| 14 | from typing import NamedTuple |
| 15 | from unittest import mock |
| 16 | |
| 17 | from chromite.lib import cros_test_lib |
| 18 | from chromite.lib import osutils |
| 19 | from chromite.scripts import tricium_clang_tidy |
| 20 | |
George Burgess IV | 853d65b | 2020-02-25 13:13:15 -0800 | [diff] [blame] | 21 | |
| 22 | class Replacement(NamedTuple): |
| 23 | """A YAML `tricium_clang_tidy.TidyReplacement`. |
| 24 | |
| 25 | The data contained in YAML is slightly different than what `TidyReplacement`s |
| 26 | carry. |
| 27 | """ |
| 28 | file_path: str |
| 29 | text: str |
| 30 | offset: int |
| 31 | length: int |
| 32 | |
| 33 | |
| 34 | class Note(NamedTuple): |
| 35 | """A clang-tidy `note` from the YAML file.""" |
| 36 | message: str |
| 37 | file_path: str |
| 38 | file_offset: int |
| 39 | |
| 40 | |
| 41 | def default_tidy_diagnostic(file_path='/tidy/file.c', |
| 42 | line_number=1, |
| 43 | diag_name='${diag_name}', |
| 44 | message='${message}', |
| 45 | replacements=(), |
| 46 | expansion_locs=()): |
| 47 | """Creates a TidyDiagnostic with reasonable defaults. |
| 48 | |
| 49 | Defaults here and yaml_diagnostic are generally intended to match where |
| 50 | possible. |
| 51 | """ |
| 52 | return tricium_clang_tidy.TidyDiagnostic( |
| 53 | file_path=file_path, |
| 54 | line_number=line_number, |
| 55 | diag_name=diag_name, |
| 56 | message=message, |
| 57 | replacements=replacements, |
| 58 | expansion_locs=expansion_locs) |
| 59 | |
| 60 | |
| 61 | def yaml_diagnostic(name='${diag_name}', |
| 62 | message='${message}', |
| 63 | file_path='/tidy/file.c', |
| 64 | file_offset=1, |
| 65 | replacements=(), |
| 66 | notes=()): |
| 67 | """Creates a diagnostic serializable as YAML with reasonable defaults.""" |
| 68 | result = { |
| 69 | 'DiagnosticName': name, |
| 70 | 'DiagnosticMessage': { |
| 71 | 'Message': message, |
| 72 | 'FilePath': file_path, |
| 73 | 'FileOffset': file_offset, |
| 74 | }, |
| 75 | } |
| 76 | |
| 77 | if replacements: |
| 78 | result['DiagnosticMessage']['Replacements'] = [{ |
| 79 | 'FilePath': x.file_path, |
| 80 | 'Offset': x.offset, |
| 81 | 'Length': x.length, |
| 82 | 'ReplacementText': x.text, |
| 83 | } for x in replacements] |
| 84 | |
| 85 | if notes: |
| 86 | result['Notes'] = [{ |
| 87 | 'Message': x.message, |
| 88 | 'FilePath': x.file_path, |
| 89 | 'FileOffset': x.file_offset, |
| 90 | } for x in notes] |
| 91 | |
| 92 | return result |
| 93 | |
| 94 | |
| 95 | def mocked_nop_realpath(f): |
| 96 | """Mocks os.path.realpath to just return its argument.""" |
| 97 | |
| 98 | @mock.patch.object(os.path, 'realpath') |
| 99 | @mock.patch.object(Path, 'resolve') |
| 100 | def inner(self, replace_mock, realpath_mock, *args, **kwargs): |
| 101 | """Mocker for realpath.""" |
| 102 | identity = lambda x: x |
| 103 | realpath_mock.side_effect = identity |
| 104 | replace_mock.side_effect = identity |
| 105 | return f(self, *args, **kwargs) |
| 106 | |
| 107 | return inner |
| 108 | |
| 109 | |
| 110 | def mocked_readonly_open(contents=None, default=None): |
| 111 | """Mocks out open() so it always returns things from |contents|. |
| 112 | |
| 113 | Writing to open'ed files is not supported. |
| 114 | |
| 115 | Args: |
| 116 | contents: a |dict| mapping |file_path| => file_contents. |
| 117 | default: a default string to return if the given |file_path| doesn't |
| 118 | exist in |contents|. |
| 119 | |
| 120 | Returns: |
| 121 | |contents[file_path]| if it exists; otherwise, |default|. |
| 122 | |
| 123 | Raises: |
| 124 | If |default| is None and |contents[file_path]| does not exist, this will |
| 125 | raise a |ValueError|. |
| 126 | """ |
| 127 | |
| 128 | if contents is None: |
| 129 | contents = {} |
| 130 | |
| 131 | def inner(f): |
| 132 | """mocked_open impl.""" |
| 133 | |
| 134 | @mock.mock_open() |
| 135 | def inner_inner(self, open_mock, *args, **kwargs): |
| 136 | """the impl of mocked_readonly_open's impl!""" |
| 137 | |
| 138 | def get_data(file_path, mode='r', encoding=None): |
| 139 | """the impl of the impl of mocked_readonly_open's impl!!""" |
| 140 | data = contents.get(file_path, default) |
| 141 | if data is None: |
| 142 | raise ValueError('No %r file was found; options were %r' % |
| 143 | (file_path, sorted(contents.keys()))) |
| 144 | |
| 145 | assert mode == 'r', f"File mode {mode} isn't supported." |
| 146 | if encoding is None: |
| 147 | return io.BytesIO(data) |
| 148 | return io.StringIO(data) |
| 149 | |
| 150 | open_mock.side_effect = get_data |
| 151 | |
| 152 | def get_data_stream(file_path): |
| 153 | return io.StringIO(get_data(file_path)) |
| 154 | |
| 155 | open_mock.side_effect = get_data_stream |
| 156 | return f(self, *args, **kwargs) |
| 157 | |
| 158 | return inner_inner |
| 159 | |
| 160 | return inner |
| 161 | |
| 162 | |
| 163 | class TriciumClangTidyTests(cros_test_lib.MockTestCase): |
| 164 | """Various tests for tricium support.""" |
| 165 | |
| 166 | def test_tidy_diagnostic_path_normalization(self): |
| 167 | expanded_from = tricium_clang_tidy.TidyExpandedFrom( |
| 168 | file_path=Path('/old2/foo'), |
| 169 | line_number=2, |
| 170 | ) |
| 171 | diag = default_tidy_diagnostic( |
| 172 | file_path=Path('/old/foo'), |
| 173 | expansion_locs=(expanded_from,), |
| 174 | ) |
| 175 | |
| 176 | normalized = diag.normalize_paths_to('/new') |
| 177 | self.assertEqual( |
| 178 | normalized, |
| 179 | diag._replace( |
| 180 | file_path=Path('../old/foo'), |
| 181 | expansion_locs=(expanded_from._replace( |
| 182 | file_path=Path('../old2/foo')),), |
| 183 | ), |
| 184 | ) |
| 185 | |
| 186 | def test_line_offest_map_works(self): |
| 187 | # (input_char, line_number_of_char, line_offset_of_char) |
| 188 | line_offset_pairs = [ |
| 189 | ('a', 1, 0), |
| 190 | ('b', 1, 1), |
| 191 | ('\n', 1, 2), |
| 192 | ('c', 2, 0), |
| 193 | ('\n', 2, 1), |
| 194 | ('\n', 3, 0), |
| 195 | ('d', 4, 0), |
| 196 | ('', 4, 1), |
| 197 | ('', 4, 2), |
| 198 | ] |
| 199 | text = tricium_clang_tidy.LineOffsetMap.for_text(''.join( |
| 200 | x for x, _, _ in line_offset_pairs)) |
| 201 | for offset, (_, line_number, line_offset) in enumerate(line_offset_pairs): |
| 202 | self.assertEqual(text.get_line_number(offset), line_number) |
| 203 | self.assertEqual(text.get_line_offset(offset), line_offset) |
| 204 | |
| 205 | def test_package_ebuild_resolution(self): |
| 206 | run_mock = self.StartPatcher(cros_test_lib.RunCommandMock()) |
| 207 | run_mock.SetDefaultCmdResult(stdout='${package1_ebuild}\n') |
| 208 | ebuilds = tricium_clang_tidy.resolve_package_ebuilds( |
| 209 | '${board}', |
| 210 | [ |
| 211 | 'package1', |
| 212 | 'package2.ebuild', |
| 213 | ], |
| 214 | ) |
| 215 | |
| 216 | run_mock.assertCommandContains(['equery-${board}', 'w', 'package1'], |
| 217 | check=True, |
| 218 | stdout=subprocess.PIPE, |
| 219 | encoding='utf-8') |
| 220 | self.assertEqual(ebuilds, ['${package1_ebuild}', 'package2.ebuild']) |
| 221 | |
| 222 | @mocked_readonly_open(default='') |
| 223 | def test_parse_tidy_invocation_returns_exception_on_error( |
| 224 | self, read_file_mock): |
| 225 | oh_no = ValueError('${oh_no}!') |
| 226 | read_file_mock.side_effect = oh_no |
| 227 | result = tricium_clang_tidy.parse_tidy_invocation( |
| 228 | Path('/some/file/that/doesnt/exist.json')) |
| 229 | self.assertIn(str(oh_no), str(result)) |
| 230 | |
| 231 | @mocked_readonly_open({ |
| 232 | '/file/path.json': |
| 233 | json.dumps({ |
| 234 | 'exit_code': 1, |
| 235 | 'executable': '${clang_tidy}', |
| 236 | 'args': ['foo', 'bar'], |
| 237 | 'lint_target': '${target}', |
| 238 | 'stdstreams': 'brrrrrrr', |
| 239 | 'wd': '/path/to/wd', |
| 240 | }), |
| 241 | # |yaml.dumps| doesn't exist, but json parses cleanly as yaml, so... |
| 242 | '/file/path.yaml': |
| 243 | json.dumps({ |
| 244 | 'Diagnostics': [ |
| 245 | yaml_diagnostic( |
| 246 | name='some-diag', |
| 247 | message='${message}', |
| 248 | file_path='', |
| 249 | ), |
| 250 | ] |
| 251 | }), |
| 252 | }) |
| 253 | def test_parse_tidy_invocation_functions_on_success(self): |
| 254 | result = tricium_clang_tidy.parse_tidy_invocation('/file/path.json') |
| 255 | # If we got an |Exception|, print it out. |
Mike Frysinger | 0828e9e | 2021-04-20 20:50:11 -0400 | [diff] [blame] | 256 | self.assertNotIsInstance(result, tricium_clang_tidy.Error) |
George Burgess IV | 853d65b | 2020-02-25 13:13:15 -0800 | [diff] [blame] | 257 | meta, info = result |
| 258 | self.assertEqual( |
| 259 | meta, |
| 260 | tricium_clang_tidy.InvocationMetadata( |
| 261 | exit_code=1, |
| 262 | invocation=['${clang_tidy}', 'foo', 'bar'], |
| 263 | lint_target='${target}', |
| 264 | stdstreams='brrrrrrr', |
| 265 | wd='/path/to/wd', |
| 266 | ), |
| 267 | ) |
| 268 | |
| 269 | self.assertEqual( |
| 270 | info, |
| 271 | [ |
| 272 | default_tidy_diagnostic( |
| 273 | diag_name='some-diag', |
| 274 | message='${message}', |
| 275 | file_path='', |
| 276 | ), |
| 277 | ], |
| 278 | ) |
| 279 | |
| 280 | @mocked_nop_realpath |
| 281 | @mocked_readonly_open(default='') |
| 282 | def test_parse_fixes_file_absolutizes_paths(self): |
| 283 | results = tricium_clang_tidy.parse_tidy_fixes_file( |
| 284 | '/tidy', { |
| 285 | 'Diagnostics': [ |
| 286 | yaml_diagnostic(file_path='foo.c'), |
| 287 | yaml_diagnostic(file_path='/tidy/bar.c'), |
| 288 | yaml_diagnostic(file_path=''), |
| 289 | ], |
| 290 | }) |
| 291 | file_paths = [x.file_path for x in results] |
| 292 | self.assertEqual(file_paths, ['/tidy/foo.c', '/tidy/bar.c', '']) |
| 293 | |
| 294 | @mocked_nop_realpath |
| 295 | @mocked_readonly_open({ |
| 296 | '/tidy/foo.c': '', |
| 297 | '/tidy/foo.h': 'a\n\n', |
| 298 | }) |
| 299 | def test_parse_fixes_file_interprets_offsets_correctly(self): |
| 300 | results = tricium_clang_tidy.parse_tidy_fixes_file( |
| 301 | '/tidy', { |
| 302 | 'Diagnostics': [ |
| 303 | yaml_diagnostic(file_path='/tidy/foo.c', file_offset=1), |
| 304 | yaml_diagnostic(file_path='/tidy/foo.c', file_offset=2), |
| 305 | yaml_diagnostic(file_path='/tidy/foo.h', file_offset=1), |
| 306 | yaml_diagnostic(file_path='/tidy/foo.h', file_offset=2), |
| 307 | yaml_diagnostic(file_path='/tidy/foo.h', file_offset=3), |
| 308 | ], |
| 309 | }) |
| 310 | file_locations = [(x.file_path, x.line_number) for x in results] |
| 311 | self.assertEqual(file_locations, [ |
| 312 | ('/tidy/foo.c', 1), |
| 313 | ('/tidy/foo.c', 1), |
| 314 | ('/tidy/foo.h', 1), |
| 315 | ('/tidy/foo.h', 2), |
| 316 | ('/tidy/foo.h', 3), |
| 317 | ]) |
| 318 | |
| 319 | @mocked_nop_realpath |
| 320 | @mocked_readonly_open({'/tidy/foo.c': 'a \n\n'}) |
| 321 | def test_parse_fixes_file_handles_replacements(self): |
| 322 | results = list( |
| 323 | tricium_clang_tidy.parse_tidy_fixes_file( |
| 324 | '/tidy', { |
| 325 | 'Diagnostics': [ |
| 326 | yaml_diagnostic( |
| 327 | file_path='/tidy/foo.c', |
| 328 | file_offset=1, |
| 329 | replacements=[ |
| 330 | Replacement( |
| 331 | file_path='foo.c', |
| 332 | text='whee', |
| 333 | offset=2, |
| 334 | length=2, |
| 335 | ), |
| 336 | ], |
| 337 | ), |
| 338 | ], |
| 339 | })) |
| 340 | self.assertEqual(len(results), 1, results) |
| 341 | self.assertEqual( |
| 342 | results[0].replacements, |
| 343 | (tricium_clang_tidy.TidyReplacement( |
| 344 | new_text='whee', |
| 345 | start_line=1, |
| 346 | end_line=3, |
| 347 | start_char=2, |
| 348 | end_char=0, |
| 349 | ),), |
| 350 | ) |
| 351 | |
| 352 | @mocked_nop_realpath |
| 353 | @mocked_readonly_open({'/whee.c': '', '/whee.h': '\n\n'}) |
| 354 | def test_parse_fixes_file_handles_macro_expansions(self): |
| 355 | results = list( |
| 356 | tricium_clang_tidy.parse_tidy_fixes_file( |
| 357 | '/tidy', { |
| 358 | 'Diagnostics': [ |
| 359 | yaml_diagnostic( |
| 360 | file_path='/whee.c', |
| 361 | file_offset=1, |
| 362 | notes=[ |
| 363 | Note( |
| 364 | message='not relevant', |
| 365 | file_path='/whee.c', |
| 366 | file_offset=1, |
| 367 | ), |
| 368 | Note( |
| 369 | message='expanded from macro "Foo"', |
| 370 | file_path='/whee.h', |
| 371 | file_offset=9, |
| 372 | ), |
| 373 | ], |
| 374 | ), |
| 375 | ], |
| 376 | })) |
| 377 | self.assertEqual(len(results), 1, results) |
| 378 | self.assertEqual( |
| 379 | results[0].expansion_locs, |
| 380 | (tricium_clang_tidy.TidyExpandedFrom( |
| 381 | file_path='/whee.h', |
| 382 | line_number=3, |
| 383 | ),), |
| 384 | ) |
| 385 | |
| 386 | @mock.patch.object(Path, 'glob') |
| 387 | @mock.patch.object(tricium_clang_tidy, 'parse_tidy_invocation') |
| 388 | def test_collect_lints_functions(self, parse_invocation_mock, glob_mock): |
| 389 | glob_mock.return_value = ('/lint/foo.json', '/lint/bar.json') |
| 390 | |
| 391 | diag_1 = default_tidy_diagnostic() |
| 392 | diag_2 = diag_1._replace(line_number=diag_1.line_number + 1) |
| 393 | diag_3 = diag_2._replace(line_number=diag_2.line_number + 1) |
| 394 | |
| 395 | # Because we want to test unique'ing, ensure these aren't equal. |
| 396 | all_diags = [diag_1, diag_2, diag_3] |
| 397 | self.assertEqual(sorted(all_diags), sorted(set(all_diags))) |
| 398 | |
| 399 | per_file_lints = { |
| 400 | '/lint/foo.json': {diag_1, diag_2}, |
| 401 | '/lint/bar.json': {diag_2, diag_3}, |
| 402 | } |
| 403 | |
| 404 | def parse_invocation_side_effect(json_file): |
| 405 | self.assertIn(json_file, per_file_lints) |
| 406 | meta = mock.Mock() |
| 407 | meta.exit_code = 0 |
| 408 | return meta, per_file_lints[json_file] |
| 409 | |
| 410 | parse_invocation_mock.side_effect = parse_invocation_side_effect |
| 411 | |
| 412 | with multiprocessing.pool.ThreadPool(1) as yaml_pool: |
| 413 | lints = tricium_clang_tidy.collect_lints(Path('/lint'), yaml_pool) |
| 414 | |
| 415 | self.assertEqual(set(all_diags), lints) |
| 416 | |
| 417 | def test_filter_tidy_lints_filters_nothing_by_default(self): |
| 418 | basis = default_tidy_diagnostic() |
| 419 | diag2 = default_tidy_diagnostic(line_number=basis.line_number + 1) |
| 420 | diags = [basis, diag2] |
| 421 | diags.sort() |
| 422 | |
| 423 | self.assertEqual( |
| 424 | diags, |
| 425 | tricium_clang_tidy.filter_tidy_lints( |
| 426 | only_files=None, |
| 427 | git_repo_base=None, |
| 428 | diags=diags, |
| 429 | ), |
| 430 | ) |
| 431 | |
| 432 | def test_filter_tidy_lints_filters_paths_outside_of_only_files(self): |
| 433 | in_only_files = default_tidy_diagnostic(file_path='foo.c') |
| 434 | out_of_only_files = default_tidy_diagnostic(file_path='bar.c') |
| 435 | self.assertEqual( |
| 436 | [in_only_files], |
| 437 | tricium_clang_tidy.filter_tidy_lints( |
| 438 | only_files={Path('foo.c')}, |
| 439 | git_repo_base=None, |
| 440 | diags=[in_only_files, out_of_only_files], |
| 441 | ), |
| 442 | ) |
| 443 | |
| 444 | def test_filter_tidy_lints_normalizes_to_git_repo_baes(self): |
| 445 | git = default_tidy_diagnostic(file_path='/git/foo.c') |
| 446 | nogit = default_tidy_diagnostic(file_path='/nogit/bar.c') |
| 447 | self.assertEqual( |
| 448 | [git.normalize_paths_to('/git')], |
| 449 | tricium_clang_tidy.filter_tidy_lints( |
| 450 | only_files=None, |
| 451 | git_repo_base=Path('/git'), |
| 452 | diags=[git, nogit], |
| 453 | ), |
| 454 | ) |
| 455 | |
| 456 | def test_filter_tidy_lints_normalizes_and_restricts_properly(self): |
| 457 | git_and_only = default_tidy_diagnostic(file_path='/git/foo.c') |
| 458 | git_and_noonly = default_tidy_diagnostic(file_path='/git/bar.c') |
| 459 | self.assertEqual( |
| 460 | [git_and_only.normalize_paths_to('/git')], |
| 461 | tricium_clang_tidy.filter_tidy_lints( |
| 462 | only_files={Path('/git/foo.c')}, |
| 463 | git_repo_base=Path('/git'), |
| 464 | diags=[git_and_only, git_and_noonly], |
| 465 | ), |
| 466 | ) |
| 467 | |
| 468 | @mock.patch.object(osutils, 'CopyDirContents') |
| 469 | @mock.patch.object(osutils, 'SafeMakedirs') |
Jack Neus | 9b76c69 | 2021-07-16 16:00:05 +0000 | [diff] [blame] | 470 | def test_lint_generation_functions(self, safe_makedirs_mock, |
George Burgess IV | 853d65b | 2020-02-25 13:13:15 -0800 | [diff] [blame] | 471 | copy_dir_contents_mock): |
| 472 | run_mock = self.StartPatcher(cros_test_lib.PopenMock()) |
| 473 | run_mock.SetDefaultCmdResult() |
| 474 | |
| 475 | # Mock mkdtemp last, since PopenMock() makes a tempdir. |
| 476 | mkdtemp_mock = self.PatchObject(tempfile, 'mkdtemp') |
| 477 | mkdtemp_path = '/path/to/temp/dir' |
| 478 | mkdtemp_mock.return_value = mkdtemp_path |
Jack Neus | 9b76c69 | 2021-07-16 16:00:05 +0000 | [diff] [blame] | 479 | with mock.patch.object(osutils, 'RmDir') as rmdir_mock: |
| 480 | dir_name = str( |
| 481 | tricium_clang_tidy.generate_lints('${board}', '/path/to/the.ebuild')) |
George Burgess IV | 853d65b | 2020-02-25 13:13:15 -0800 | [diff] [blame] | 482 | self.assertEqual(mkdtemp_path, dir_name) |
| 483 | |
| 484 | rmdir_mock.assert_called_with( |
| 485 | tricium_clang_tidy.LINT_BASE, ignore_missing=True, sudo=True) |
| 486 | safe_makedirs_mock.assert_called_with( |
| 487 | tricium_clang_tidy.LINT_BASE, 0o777, sudo=True) |
| 488 | |
| 489 | desired_env = dict(os.environ) |
| 490 | desired_env['WITH_TIDY'] = 'tricium' |
| 491 | run_mock.assertCommandContains( |
| 492 | ['ebuild-${board}', '/path/to/the.ebuild', 'clean', 'compile'], |
| 493 | env=desired_env) |
| 494 | |
| 495 | copy_dir_contents_mock.assert_called_with(tricium_clang_tidy.LINT_BASE, |
| 496 | dir_name) |