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