1# Copyright (C) 2013-2020 ycmd contributors 2# 3# This file is part of ycmd. 4# 5# ycmd is free software: you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# ycmd is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with ycmd. If not, see <http://www.gnu.org/licenses/>. 17 18 19from hamcrest import ( assert_that, 20 contains_exactly, 21 contains_string, 22 empty, 23 equal_to, 24 has_entries, 25 has_entry, 26 has_item ) 27from unittest.mock import patch 28from pprint import pformat 29from webtest import TestApp 30import bottle 31import contextlib 32import pytest 33import functools 34import os 35import tempfile 36import time 37import stat 38import shutil 39import json 40 41from ycmd import extra_conf_store, handlers, user_options_store 42from ycmd.completers.completer import Completer 43from ycmd.responses import BuildCompletionData 44from ycmd.utils import ( GetCurrentDirectory, 45 ImportCore, 46 OnMac, 47 OnWindows, 48 ToUnicode, 49 WaitUntilProcessIsTerminated ) 50ycm_core = ImportCore() 51 52from unittest import skipIf 53 54TESTS_DIR = os.path.abspath( os.path.dirname( __file__ ) ) 55TEST_OPTIONS = { 56 # The 'client' represented by the tests supports on-demand resolve, but the 57 # server default config doesn't for backward compatibility 58 'max_num_candidates_to_detail': 10 59} 60 61WindowsOnly = skipIf( not OnWindows(), 'Windows only' ) 62ClangOnly = skipIf( not ycm_core.HasClangSupport(), 63 'Only when Clang support available' ) 64MacOnly = skipIf( not OnMac(), 'Mac only' ) 65UnixOnly = skipIf( OnWindows(), 'Unix only' ) 66 67EMPTY_SIGNATURE_HELP = has_entries( { 68 'activeParameter': 0, 69 'activeSignature': 0, 70 'signatures': empty(), 71} ) 72 73 74def BuildRequest( **kwargs ): 75 filepath = kwargs[ 'filepath' ] if 'filepath' in kwargs else '/foo' 76 contents = kwargs[ 'contents' ] if 'contents' in kwargs else '' 77 filetype = kwargs[ 'filetype' ] if 'filetype' in kwargs else 'foo' 78 filetypes = kwargs[ 'filetypes' ] if 'filetypes' in kwargs else [ filetype ] 79 80 request = { 81 'line_num': 1, 82 'column_num': 1, 83 'filepath': filepath, 84 'file_data': { 85 filepath: { 86 'contents': contents, 87 'filetypes': filetypes 88 } 89 } 90 } 91 92 for key, value in kwargs.items(): 93 if key in [ 'contents', 'filetype', 'filepath' ]: 94 continue 95 96 if key in request and isinstance( request[ key ], dict ): 97 # allow updating the 'file_data' entry 98 request[ key ].update( value ) 99 else: 100 request[ key ] = value 101 102 return request 103 104 105def CombineRequest( request, data ): 106 kwargs = request.copy() 107 kwargs.update( data ) 108 return BuildRequest( **kwargs ) 109 110 111def ErrorMatcher( cls, msg = None ): 112 """ Returns a hamcrest matcher for a server exception response """ 113 entry = { 'exception': has_entry( 'TYPE', cls.__name__ ) } 114 115 if msg: 116 entry.update( { 'message': msg } ) 117 118 return has_entries( entry ) 119 120 121def CompletionEntryMatcher( insertion_text, 122 extra_menu_info = None, 123 extra_params = None ): 124 match = { 'insertion_text': insertion_text } 125 126 if extra_menu_info: 127 match.update( { 'extra_menu_info': extra_menu_info } ) 128 129 if extra_params: 130 match.update( extra_params ) 131 132 return has_entries( match ) 133 134 135def MessageMatcher( msg ): 136 return has_entry( 'message', contains_string( msg ) ) 137 138 139def LocationMatcher( filepath, 140 line_num, 141 column_num, 142 description=None, 143 extra_data=None ): 144 entry = { 145 'line_num': line_num, 146 'column_num': column_num, 147 'filepath': filepath 148 } 149 if description is not None: 150 entry[ 'description' ] = description 151 if extra_data is not None: 152 entry[ 'extra_data' ] = has_entries( **extra_data ) 153 154 return has_entries( entry ) 155 156 157def RangeMatcher( filepath, start, end ): 158 return has_entries( { 159 'start': LocationMatcher( filepath, *start ), 160 'end': LocationMatcher( filepath, *end ), 161 } ) 162 163 164def ChunkMatcher( replacement_text, start, end ): 165 return has_entries( { 166 'replacement_text': replacement_text, 167 'range': has_entries( { 168 'start': start, 169 'end': end 170 } ) 171 } ) 172 173 174def LineColMatcher( line, col ): 175 return has_entries( { 176 'line_num': line, 177 'column_num': col 178 } ) 179 180 181def CompleterProjectDirectoryMatcher( project_directory ): 182 return has_entry( 183 'completer', 184 has_entry( 'servers', contains_exactly( 185 has_entry( 'extras', has_item( 186 has_entries( { 187 'key': 'Project Directory', 188 'value': project_directory, 189 } ) 190 ) ) 191 ) ) 192 ) 193 194 195def SignatureMatcher( label, parameters, docs = None ): 196 entries = { 197 'label': equal_to( label ), 198 'parameters': contains_exactly( *parameters ) 199 } 200 if docs is not None: 201 entries.update( { 'documentation': docs } ) 202 return has_entries( entries ) 203 204 205def SignatureAvailableMatcher( available ): 206 return has_entries( { 'available': equal_to( available ) } ) 207 208 209def ParameterMatcher( begin, end, docs = None ): 210 entries = { 'label': contains_exactly( begin, end ) } 211 if docs is not None: 212 entries.update( { 'documentation': docs } ) 213 return has_entries( entries ) 214 215 216@contextlib.contextmanager 217def PatchCompleter( completer, filetype ): 218 user_options = handlers._server_state._user_options 219 with patch.dict( 'ycmd.handlers._server_state._filetype_completers', 220 { filetype: completer( user_options ) } ): 221 yield 222 223 224@contextlib.contextmanager 225def CurrentWorkingDirectory( path ): 226 old_cwd = GetCurrentDirectory() 227 os.chdir( path ) 228 try: 229 yield old_cwd 230 finally: 231 os.chdir( old_cwd ) 232 233 234# The "exe" suffix is needed on Windows and not harmful on other platforms. 235@contextlib.contextmanager 236def TemporaryExecutable( extension = '.exe' ): 237 with tempfile.NamedTemporaryFile( prefix = 'Temp', 238 suffix = extension ) as executable: 239 os.chmod( executable.name, stat.S_IXUSR ) 240 yield executable.name 241 242 243@contextlib.contextmanager 244def TemporarySymlink( source, link ): 245 os.symlink( source, link ) 246 try: 247 yield 248 finally: 249 os.remove( link ) 250 251 252def SetUpApp( custom_options = {} ): 253 bottle.debug( True ) 254 options = user_options_store.DefaultOptions() 255 options.update( TEST_OPTIONS ) 256 options.update( custom_options ) 257 handlers.UpdateUserOptions( options ) 258 extra_conf_store.Reset() 259 return TestApp( handlers.app ) 260 261 262@contextlib.contextmanager 263def IgnoreExtraConfOutsideTestsFolder(): 264 with patch( 'ycmd.utils.IsRootDirectory', 265 lambda path, parent: path in [ parent, TESTS_DIR ] ): 266 yield 267 268 269@contextlib.contextmanager 270def IsolatedApp( custom_options = {} ): 271 old_server_state = handlers._server_state 272 old_extra_conf_store_state = extra_conf_store.Get() 273 old_options = user_options_store.GetAll() 274 try: 275 with IgnoreExtraConfOutsideTestsFolder(): 276 yield SetUpApp( custom_options ) 277 finally: 278 handlers._server_state = old_server_state 279 extra_conf_store.Set( old_extra_conf_store_state ) 280 user_options_store.SetAll( old_options ) 281 282 283def StartCompleterServer( app, filetype, filepath = '/foo' ): 284 app.post_json( '/run_completer_command', 285 BuildRequest( command_arguments = [ 'RestartServer' ], 286 filetype = filetype, 287 filepath = filepath ) ) 288 289 290def StopCompleterServer( app, filetype, filepath = '/foo' ): 291 app.post_json( '/run_completer_command', 292 BuildRequest( command_arguments = [ 'StopServer' ], 293 filetype = filetype, 294 filepath = filepath ), 295 expect_errors = True ) 296 297 298def WaitUntilCompleterServerReady( app, filetype, timeout = 30 ): 299 expiration = time.time() + timeout 300 while True: 301 if time.time() > expiration: 302 raise RuntimeError( f'Waited for the { filetype } subserver to be ready ' 303 f'for { timeout } seconds, aborting.' ) 304 305 if app.get( '/ready', { 'subserver': filetype } ).json: 306 return 307 308 time.sleep( 0.1 ) 309 310 311def MockProcessTerminationTimingOut( handle, timeout = 5 ): 312 WaitUntilProcessIsTerminated( handle, timeout ) 313 raise RuntimeError( f'Waited process to terminate for { timeout } seconds, ' 314 'aborting.' ) 315 316 317def ClearCompletionsCache(): 318 """Invalidates cached completions for completers stored in the server state: 319 filetype completers and general completers (identifier, filename, and 320 ultisnips completers). 321 322 This function is used when sharing the application between tests so that 323 no completions are cached by previous tests.""" 324 server_state = handlers._server_state 325 for completer in server_state.GetLoadedFiletypeCompleters(): 326 completer._completions_cache.Invalidate() 327 general_completer = server_state.GetGeneralCompleter() 328 for completer in general_completer._all_completers: 329 completer._completions_cache.Invalidate() 330 331 332class DummyCompleter( Completer ): 333 def __init__( self, user_options ): 334 super().__init__( user_options ) 335 336 def SupportedFiletypes( self ): 337 return [] 338 339 340 def ComputeCandidatesInner( self, request_data ): 341 return [ BuildCompletionData( candidate ) 342 for candidate in self.CandidatesList() ] 343 344 345 # This method is here for testing purpose, so it can be mocked during tests 346 def CandidatesList( self ): 347 return [] 348 349 350def ExpectedFailure( reason, *exception_matchers ): 351 """Defines a decorator to be attached to tests. This decorator 352 marks the test as being known to fail, e.g. where documenting or exercising 353 known incorrect behaviour. 354 355 The parameters are: 356 - |reason| a textual description of the reason for the known issue. This 357 is used for the skip reason 358 - |exception_matchers| additional arguments are hamcrest matchers to apply 359 to the exception thrown. If the matchers don't match, then the 360 test is marked as error, with the original exception. 361 362 If the test fails (for the correct reason), then it is marked as skipped. 363 If it fails for any other reason, it is marked as failed. 364 If the test passes, then it is also marked as failed.""" 365 def decorator( test ): 366 @functools.wraps( test ) 367 def Wrapper( *args, **kwargs ): 368 try: 369 test( *args, **kwargs ) 370 except Exception as test_exception: 371 # Ensure that we failed for the right reason 372 test_exception_message = ToUnicode( test_exception ) 373 try: 374 for matcher in exception_matchers: 375 assert_that( test_exception_message, matcher ) 376 except AssertionError: 377 # Failed for the wrong reason! 378 import traceback 379 print( 'Test failed for the wrong reason: ' + traceback.format_exc() ) 380 # Real failure reason is the *original* exception, we're only trapping 381 # and ignoring the exception that is expected. 382 raise test_exception 383 384 # Failed for the right reason 385 pytest.skip( reason ) 386 else: 387 raise AssertionError( f'Test was expected to fail: { reason }' ) 388 return Wrapper 389 390 return decorator 391 392 393@contextlib.contextmanager 394def TemporaryTestDir(): 395 """Context manager to execute a test with a temporary workspace area. The 396 workspace is deleted upon completion of the test. This is useful particularly 397 for testing project detection (e.g. compilation databases, etc.), by ensuring 398 that the directory is empty and not affected by the user's filesystem.""" 399 tmp_dir = tempfile.mkdtemp() 400 try: 401 yield tmp_dir 402 finally: 403 shutil.rmtree( tmp_dir ) 404 405 406def WithRetry( *args, **kwargs ): 407 """Decorator to be applied to tests that retries the test over and over""" 408 409 if len( args ) == 1 and callable( args[ 0 ] ): 410 # We are the decorator 411 f = args[ 0 ] 412 413 def ReturnDecorator( wrapper ): 414 return wrapper( f ) 415 else: 416 # We need to return the decorator 417 def ReturnDecorator( wrapper ): 418 return wrapper 419 420 if os.environ.get( 'YCM_TEST_NO_RETRY' ) == 'XFAIL': 421 return ReturnDecorator( pytest.mark.xfail( strict = False ) ) 422 elif os.environ.get( 'YCM_TEST_NO_RETRY' ): 423 # This is a "null" decorator 424 return ReturnDecorator( lambda f: f ) 425 else: 426 opts = { 'reruns': 20, 'reruns_delay': 0.5 } 427 opts.update( kwargs ) 428 return ReturnDecorator( pytest.mark.flaky( **opts ) ) 429 430 431@contextlib.contextmanager 432def TemporaryClangProject( tmp_dir, compile_commands ): 433 """Context manager to create a compilation database in a directory and delete 434 it when the test completes. |tmp_dir| is the directory in which to create the 435 database file (typically used in conjunction with |TemporaryTestDir|) and 436 |compile_commands| is a python object representing the compilation database. 437 438 e.g.: 439 with TemporaryTestDir() as tmp_dir: 440 database = [ 441 { 442 'directory': os.path.join( tmp_dir, dir ), 443 'command': compiler_invocation, 444 'file': os.path.join( tmp_dir, dir, filename ) 445 }, 446 ... 447 ] 448 with TemporaryClangProject( tmp_dir, database ): 449 <test here> 450 451 The context manager does not yield anything. 452 """ 453 path = os.path.join( tmp_dir, 'compile_commands.json' ) 454 455 with open( path, 'w' ) as f: 456 f.write( ToUnicode( json.dumps( compile_commands, indent = 2 ) ) ) 457 458 try: 459 yield 460 finally: 461 os.remove( path ) 462 463 464def WaitForDiagnosticsToBeReady( app, filepath, contents, filetype, **kwargs ): 465 results = None 466 for tries in range( 0, 60 ): 467 event_data = BuildRequest( event_name = 'FileReadyToParse', 468 contents = contents, 469 filepath = filepath, 470 filetype = filetype, 471 **kwargs ) 472 473 results = app.post_json( '/event_notification', event_data ).json 474 475 if results: 476 break 477 478 time.sleep( 0.5 ) 479 480 return results 481 482 483class PollForMessagesTimeoutException( Exception ): 484 pass 485 486 487def PollForMessages( app, request_data, timeout = 60 ): 488 expiration = time.time() + timeout 489 while True: 490 if time.time() > expiration: 491 raise PollForMessagesTimeoutException( 'Waited for diagnostics to be ' 492 f'ready for { timeout } seconds, aborting.' ) 493 494 default_args = { 495 'line_num' : 1, 496 'column_num': 1, 497 } 498 args = dict( default_args ) 499 args.update( request_data ) 500 501 response = app.post_json( '/receive_messages', BuildRequest( **args ) ).json 502 503 print( f'poll response: { pformat( response ) }' ) 504 505 if isinstance( response, bool ): 506 if not response: 507 raise RuntimeError( 'The message poll was aborted by the server' ) 508 elif isinstance( response, list ): 509 for message in response: 510 yield message 511 else: 512 raise AssertionError( 513 f'Message poll response was wrong type: { type( response ).__name__ }' ) 514 515 time.sleep( 0.25 ) 516