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