Surround highlighted identifier with backticks to allow nested markdown formatting...
[hiphop-php.git] / hphp / hack / test / integration / common_tests.py
blobd4aed106f15b12a3651a66c3d09e57515b3da1e2
1 # pyre-strict
3 from __future__ import absolute_import, division, print_function, unicode_literals
5 import json
6 import os
7 import re
8 import shutil
9 import signal
10 import subprocess
11 import sys
12 import tempfile
13 import time
14 from typing import ClassVar, List, Mapping, Optional, Tuple
16 from hh_paths import hackfmt, hh_client, hh_merge_deps, hh_server
17 from test_case import TestCase, TestDriver
18 from utils import Json, JsonObject
21 class DebugSubscription(object):
22 """
23 Wraps `hh_client debug`.
24 """
26 # pyre-fixme[24]: Generic type `subprocess.Popen` expects 1 type parameter.
27 def __init__(self, proc: subprocess.Popen) -> None:
28 self.proc = proc
29 hello = self.read_msg()
30 assert hello["data"] == "hello"
32 # pyre-fixme[11]: Annotation `Json` is not defined as a type.
33 def read_msg(self) -> Json:
34 # pyre-fixme[16]: `Optional` has no attribute `readline`.
35 line = self.proc.stdout.readline()
36 return json.loads(line)
38 # pyre-fixme[11]: Annotation `JsonObject` is not defined as a type.
39 def get_incremental_logs(self) -> JsonObject:
40 msgs = {}
41 while True:
42 msg = self.read_msg()
43 if msg["type"] == "info" and msg["data"] == "incremental_done":
44 break
45 msgs[msg["name"]] = msg
46 return msgs
49 class CommonTestDriver(TestDriver):
51 # This needs to be overridden in child classes. The files in this
52 # directory will be used to set up the initial environment for each
53 # test.
54 template_repo: ClassVar[str]
55 repo_dir: ClassVar[str]
56 test_env: ClassVar[Mapping[str, str]]
57 base_tmp_dir: ClassVar[str]
58 hh_tmp_dir: ClassVar[str]
59 bin_dir: ClassVar[str]
61 @classmethod
62 def setUpClass(cls, template_repo: str) -> None:
63 cls.template_repo = template_repo
64 cls.maxDiff = 2000
65 cls.base_tmp_dir = tempfile.mkdtemp()
66 # we don't create repo_dir using mkdtemp() because we want to create
67 # it with copytree(). copytree() will fail if the directory already
68 # exists.
69 cls.repo_dir = os.path.join(cls.base_tmp_dir, "repo")
70 # Where the hhi files, socket, etc get extracted
71 cls.hh_tmp_dir = tempfile.mkdtemp()
72 cls.bin_dir = tempfile.mkdtemp()
73 hh_server_dir = os.path.dirname(hh_server)
75 hh_merge_deps_dir = os.path.dirname(hh_merge_deps)
76 print("hh_server_dir " + hh_server_dir)
77 print("hh_merge_deps_dir " + hh_merge_deps_dir)
79 cls.test_env = dict(
80 os.environ,
81 **{
82 "HH_TEST_MODE": "1",
83 "HH_TMPDIR": cls.hh_tmp_dir,
84 "PATH": (
85 "%s:%s:%s:/bin:/usr/bin:/usr/local/bin"
86 % (hh_server_dir, hh_merge_deps_dir, cls.bin_dir)
88 "HH_HOME": os.path.dirname(hh_client),
89 "OCAMLRUNPARAM": "b",
90 "HH_LOCALCONF_PATH": cls.repo_dir,
94 @classmethod
95 def tearDownClass(cls) -> None:
96 shutil.rmtree(cls.base_tmp_dir)
97 shutil.rmtree(cls.bin_dir)
98 shutil.rmtree(cls.hh_tmp_dir)
100 def write_load_config(self, use_saved_state: bool = False) -> None:
102 Writes out a script that will print the list of changed files,
103 and adds the path to that script to .hhconfig
105 raise NotImplementedError()
107 def wait_until_server_ready(self) -> None:
109 We don't want to accidentally connect to an old hh_server, so we wait 2
110 seconds for the monitor to start up the new server first.
112 time.sleep(2)
113 self.run_check()
115 def start_hh_server(
116 self,
117 changed_files: Optional[List[str]] = None,
118 saved_state_path: Optional[str] = None,
119 args: Optional[List[str]] = None,
120 ) -> None:
121 """ Start an hh_server. changed_files is ignored here (as it
122 has no meaning) and is only exposed in this API for the derived
123 classes.
125 if changed_files is None:
126 changed_files = []
127 if args is None:
128 args = []
129 cmd = [hh_server, "--daemon", "--max-procs", "2", self.repo_dir] + args
130 self.proc_call(cmd)
131 self.wait_until_server_ready()
133 def stop_hh_server(self, retries: int = 3) -> None:
134 (_, _, exit_code) = self.proc_call([hh_client, "stop", self.repo_dir])
135 if exit_code == 0:
136 return
137 elif retries > 0 and exit_code != 0:
138 self.stop_hh_server(retries=retries - 1)
139 else:
140 self.assertEqual(exit_code, 0, msg="Stopping hh_server failed")
142 def get_server_logs(self) -> str:
143 time.sleep(2) # wait for logs to be written
144 log_file = self.proc_call([hh_client, "--logname", self.repo_dir])[0].strip()
145 with open(log_file) as f:
146 return f.read()
148 def get_monitor_logs(self) -> str:
149 time.sleep(2) # wait for logs to be written
150 log_file = self.proc_call([hh_client, "--monitor-logname", self.repo_dir])[
152 ].strip()
153 with open(log_file) as f:
154 return f.read()
156 def setUp(self) -> None:
157 shutil.copytree(self.template_repo, self.repo_dir)
159 def tearDownWithRetries(self, retries: int = 3) -> None:
160 self.stop_hh_server(retries=retries)
161 shutil.rmtree(self.repo_dir)
163 def tearDown(self) -> None:
164 self.tearDownWithRetries()
166 @classmethod
167 # pyre-fixme[24]: Generic type `subprocess.Popen` expects 1 type parameter.
168 def proc_create(cls, args: List[str], env: Mapping[str, str]) -> subprocess.Popen:
169 return subprocess.Popen(
170 args,
171 stdin=subprocess.PIPE,
172 stdout=subprocess.PIPE,
173 stderr=subprocess.PIPE,
174 env=dict(cls.test_env, **env),
175 universal_newlines=True,
178 @classmethod
179 def proc_call(
180 cls,
181 args: List[str],
182 env: Optional[Mapping[str, str]] = None,
183 stdin: Optional[str] = None,
184 ) -> Tuple[str, str, int]:
186 Invoke a subprocess, return stdout, send stderr to our stderr (for
187 debugging)
189 env = {} if env is None else env
190 print(" ".join(args), file=sys.stderr)
191 proc = cls.proc_create(args, env)
192 (stdout_data, stderr_data) = proc.communicate(stdin)
193 sys.stderr.write(stderr_data)
194 sys.stderr.flush()
195 retcode = proc.wait()
196 return (stdout_data, stderr_data, retcode)
198 @classmethod
199 def wait_pid_with_timeout(cls, pid: int, timeout: int) -> None:
201 Like os.waitpid but with a timeout in seconds.
203 waited_time = 0
204 while True:
205 pid_expected, _ = os.waitpid(pid, os.WNOHANG)
206 if pid_expected == pid:
207 break
208 elif waited_time >= timeout:
209 raise subprocess.TimeoutExpired
210 else:
211 time.sleep(1)
212 waited_time += 1
214 def run_check(
215 self, stdin: Optional[str] = None, options: Optional[List[str]] = None
216 ) -> Tuple[str, str, int]:
217 options = [] if options is None else options
218 root = self.repo_dir + os.path.sep
219 return self.proc_call(
221 hh_client,
222 "check",
223 "--retries",
224 "20",
225 "--error-format",
226 "raw",
227 self.repo_dir,
229 + list(map(lambda x: x.format(root=root), options)),
230 stdin=stdin,
233 # Check to see if you can run hackfmt
234 def run_hackfmt_check(self) -> bool:
235 try:
237 (stdout_data, stderr_data, retcode) = self.proc_call(["hackfmt", "-help"])
238 return retcode == 0
239 # If the file isn't found you will get this
240 except FileNotFoundError:
241 return False
243 def run_hackfmt(
244 self,
245 stdin: Optional[str] = None,
246 options: Optional[List[str]] = None,
247 expected_output: Optional[str] = None,
248 ) -> bool:
249 options = [] if options is None else options
250 (output, err, retcode) = self.proc_call([hackfmt] + options, stdin=stdin)
251 if retcode != 0:
252 print("check returned non-zero code: " + str(retcode), file=sys.stderr)
253 if expected_output is not None:
254 self.assertEqual(expected_output, output)
255 return True
257 # Runs `hh_client check` asserting the stdout is equal the expected.
258 # Returns stderr.
259 # Note: assert_laoded_mini_state is ignored here and only used
260 # in some derived classes.
261 def check_cmd(
262 self,
263 expected_output: Optional[List[str]],
264 stdin: Optional[str] = None,
265 options: Optional[List[str]] = None,
266 assert_loaded_saved_state: bool = False,
267 ) -> str:
268 (output, err, retcode) = self.run_check(stdin, options)
269 root = self.repo_dir + os.path.sep
270 if retcode != 0:
271 print("check returned non-zero code: " + str(retcode), file=sys.stderr)
272 if expected_output is not None:
273 # pyre-fixme[8]: Attribute has type `int`; used as `None`.
274 self.maxDiff = None
275 self.assertCountEqual(
276 map(lambda x: x.format(root=root), expected_output), output.splitlines()
278 return err
280 def check_cmd_and_json_cmd(
281 self,
282 expected_output: List[str],
283 expected_json: List[str],
284 stdin: Optional[str] = None,
285 options: Optional[List[str]] = None,
286 ) -> None:
287 # we run the --json version first because --json --refactor doesn't
288 # change any files, but plain --refactor does (i.e. the latter isn't
289 # idempotent)
290 if options is None:
291 options = []
292 self.check_cmd(expected_json, stdin, options + ["--json"])
293 self.check_cmd(expected_output, stdin, options)
295 def subscribe_debug(self) -> DebugSubscription:
296 proc = self.proc_create([hh_client, "debug", self.repo_dir], env={})
297 return DebugSubscription(proc)
299 def start_hh_loop_forever_assert_timeout(self) -> None:
300 # create a file with 10 dependencies. Only "big" jobs, that use
301 # workers can be interrupted at the moment.
302 with open(os.path.join(self.repo_dir, "__hh_loop_forever_foo.php"), "w") as f:
303 f.write(
304 """<?hh //strict
305 function __hh_loop_forever_foo(): int {
306 return 4;
307 }"""
310 for i in range(1, 10):
311 with open(
312 os.path.join(self.repo_dir, "__hh_loop_forever_bar%d.php" % i), "w"
313 ) as f:
314 f.write(
315 """<?hh //strict
316 function __hh_loop_forever_bar%d(): int {
317 return __hh_loop_forever_foo();
318 }"""
322 self.check_cmd(["No errors!"])
324 # trigger rechecking of all 11 files, and make one of them loop
325 # until cancelled
326 with open(os.path.join(self.repo_dir, "__hh_loop_forever_foo.php"), "w") as f:
327 f.write(
328 """<?hh //strict
329 function __hh_loop_forever_foo(): string {
330 hh_loop_forever();
331 }"""
334 # this should timeout due to infinite loop
335 try:
336 # empty output means no results due to timeout
337 self.check_cmd([], options=["--retries", "1"])
338 except AssertionError:
339 # one of the test drivers doesn't like timeouts
340 pass
342 def stop_hh_loop_forever(self) -> None:
343 # subsequent change should interrupt the "loop forever" part
344 with open(os.path.join(self.repo_dir, "__hh_loop_forever_foo.php"), "w") as f:
345 f.write(
346 """<?hh //strict
347 function __hh_loop_forever_foo(): int {
348 return 4;
349 }"""
352 self.check_cmd(["No errors!"])
355 # The most basic of tests.
356 # Exercises server responsiveness, and updating errors after changing files
357 class BarebonesTests(TestCase[CommonTestDriver]):
359 # hh should should work with 0 retries.
360 def test_responsiveness(self) -> None:
361 self.test_driver.start_hh_server()
362 self.test_driver.check_cmd(["No errors!"])
363 self.test_driver.check_cmd(["No errors!"], options=["--retries", "0"])
365 def test_new_file(self) -> None:
367 Add a new file that contains an error.
369 with open(os.path.join(self.test_driver.repo_dir, "foo_4.php"), "w") as f:
370 f.write(
371 """<?hh
373 function k(): int {
374 return 'a';
379 self.test_driver.start_hh_server(changed_files=["foo_4.php"])
381 self.test_driver.check_cmd(
383 "{root}foo_4.php:4:24,26: Invalid return type (Typing[4110])",
384 " {root}foo_4.php:3:27,29: Expected `int`",
385 " {root}foo_4.php:4:24,26: But got `string`",
389 def test_new_naming_error(self) -> None:
391 Add a new file which contains a naming collisions with an old file
393 with open(os.path.join(self.test_driver.repo_dir, "foo_4.php"), "w") as f:
394 f.write(
395 """<?hh //partial
397 class FOO {}
398 function H () {}
402 self.test_driver.start_hh_server(changed_files=["foo_4.php"])
404 self.test_driver.check_cmd(
406 "{root}foo_4.php:3:19,21: Name already bound: `FOO` (Naming[2012])",
407 " {root}foo_3.php:7:15,17: Previous definition `F~~oo~~` differs only by case ",
408 "{root}foo_4.php:4:22,22: Name already bound: `H` (Naming[2012])",
409 " {root}foo_3.php:3:18,18: Previous definition `~~h~~` differs only by case ",
413 # We put this test in Barebones tests so that dependencies on class B
414 # show an error (i.e. class_3.php) with both the save state driver
415 # and the classic save state driver
416 def test_modify_extends_deps(self) -> None:
418 Introduce a change to a base class that causes an error
419 in a use case on one of its subclasses.
421 with open(os.path.join(self.test_driver.repo_dir, "class_1.php"), "w") as f:
422 f.write(
423 """<?hh // strict
425 class B {
426 public static function foo () : bool {
427 return true;
432 self.test_driver.start_hh_server(changed_files=["class_1.php"])
433 self.test_driver.check_cmd(
435 "{root}class_3.php:5:12,19: Invalid return type (Typing[4110])",
436 " {root}class_3.php:4:28,30: Expected `int`",
437 " {root}class_1.php:4:51,54: But got `bool`",
442 # Common tests, includes the Barebones Tests above
443 class CommonTests(BarebonesTests):
444 def test_json_errors(self) -> None:
446 If you ask for errors in JSON format, you will get them on standard
447 output. Changing this will break the tools that depend on it (like
448 editor plugins), and this test is here to remind you about it.
450 self.test_driver.start_hh_server()
452 stderr = self.test_driver.check_cmd([], options=["--json"])
453 last_line = stderr.splitlines()[-1]
454 output = json.loads(last_line)
456 self.assertEqual(output["errors"], [])
457 self.assertEqual(output["passed"], True)
458 self.assertIn("version", output)
460 def test_modify_file(self) -> None:
462 Add an error to a file that previously had none.
464 with open(os.path.join(self.test_driver.repo_dir, "foo_2.php"), "w") as f:
465 f.write(
466 """<?hh
468 function g(): int {
469 return 'a';
474 self.test_driver.start_hh_server(changed_files=["foo_2.php"])
476 self.test_driver.check_cmd(
478 "{root}foo_2.php:4:24,26: Invalid return type (Typing[4110])",
479 " {root}foo_2.php:3:27,29: Expected `int`",
480 " {root}foo_2.php:4:24,26: But got `string`",
484 def test_deleted_file(self) -> None:
486 Delete a file that still has dangling references before restoring from
487 a saved state.
489 os.remove(os.path.join(self.test_driver.repo_dir, "foo_2.php"))
491 self.test_driver.start_hh_server(changed_files=["foo_2.php"])
493 self.test_driver.check_cmd(
495 "{root}foo_1.php:4:20,20: Unbound name: `g` (a global function) (Naming[2049])"
499 def test_file_delete_after_load(self) -> None:
501 Delete a file that still has dangling references after restoring from
502 a saved state.
504 self.test_driver.start_hh_server()
505 self.test_driver.check_cmd(["No errors!"])
506 debug_sub = self.test_driver.subscribe_debug()
508 os.remove(os.path.join(self.test_driver.repo_dir, "foo_2.php"))
509 msgs = debug_sub.get_incremental_logs()
510 self.assertEqual(msgs["to_redecl_phase1"]["files"], ["foo_2.php"])
511 self.assertEqual(msgs["to_redecl_phase2"]["files"], ["foo_2.php"])
512 self.assertEqual(
513 set(msgs["to_recheck"]["files"]), set(["foo_1.php", "foo_2.php"])
515 self.test_driver.check_cmd(
517 "{root}foo_1.php:4:20,20: Unbound name: `g` (a global function) (Naming[2049])"
521 def test_duplicated_file(self) -> None:
522 self.test_driver.start_hh_server(changed_files=["foo_2.php"])
523 self.test_driver.check_cmd(["No errors!"])
525 shutil.copyfile(
526 os.path.join(self.test_driver.repo_dir, "foo_2.php"),
527 os.path.join(self.test_driver.repo_dir, "foo_2_dup.php"),
530 self.test_driver.check_cmd(
532 "{root}foo_2_dup.php:3:18,18: Name already bound: `g` (Naming[2012])",
533 " {root}foo_2.php:3:18,18: Previous definition is here",
537 os.remove(os.path.join(self.test_driver.repo_dir, "foo_2.php"))
538 self.test_driver.check_cmd(["No errors!"])
540 def test_moved_file(self) -> None:
542 Move a file, then create an error that references a definition in it.
543 Check that the new file name is displayed in the error.
546 self.test_driver.start_hh_server(
547 changed_files=["foo_1.php", "foo_2.php", "bar_2.php"]
550 os.rename(
551 os.path.join(self.test_driver.repo_dir, "foo_2.php"),
552 os.path.join(self.test_driver.repo_dir, "bar_2.php"),
555 with open(os.path.join(self.test_driver.repo_dir, "foo_1.php"), "w") as f:
556 f.write(
557 """<?hh
559 function f(): string {
560 return g();
565 self.test_driver.check_cmd(
567 "{root}foo_1.php:4:24,26: Invalid return type (Typing[4110])",
568 " {root}foo_1.php:3:27,32: Expected `string`",
569 " {root}bar_2.php:3:23,25: But got `int`",
573 def test_find_refs(self) -> None:
575 Test hh_client --find-refs, --find-class-refs
577 self.test_driver.start_hh_server()
579 self.test_driver.check_cmd_and_json_cmd(
580 ['File "{root}foo_3.php", line 11, characters 13-13: h', "1 total results"],
582 '[{{"name":"h","filename":"{root}foo_3.php","line":11,"char_start":13,"char_end":13}}]'
584 options=["--find-refs", "h"],
587 self.test_driver.check_cmd_and_json_cmd(
589 'File "{root}foo_3.php", line 10, characters 17-19: Foo::__construct',
590 "1 total results",
593 '[{{"name":"Foo::__construct","filename":"{root}foo_3.php","line":10,"char_start":17,"char_end":19}}]'
595 options=["--find-refs", "Foo::__construct"],
598 self.test_driver.check_cmd_and_json_cmd(
600 'File "{root}foo_3.php", line 10, characters 17-19: Foo',
601 "1 total results",
604 '[{{"name":"Foo","filename":"{root}foo_3.php","line":10,'
605 '"char_start":17,"char_end":19}}]'
607 options=["--find-class-refs", "Foo"],
610 def test_identify_symbol(self) -> None:
612 Test hh_client --identify
614 self.test_driver.start_hh_server()
616 self.test_driver.check_cmd_and_json_cmd(
619 '[{{"full_name":"B","pos":{{"filename":"{root}class_1.php","line":3,"char_start":7,"char_end":7}},"kind":"class"}}]'
621 options=["--identify", "B"],
624 self.test_driver.check_cmd_and_json_cmd(
627 '[{{"full_name":"B::foo","pos":{{"filename":"{root}class_1.php","line":5,"char_start":26,"char_end":28}},"kind":"method"}}]'
629 options=["--identify", "B::foo"],
632 self.test_driver.check_cmd_and_json_cmd(
635 '[{{"full_name":"CONST_SOME_COOL_VALUE","pos":{{"filename":"{root}const_1.php","line":3,"char_start":11,"char_end":31}},"kind":"const"}}]'
637 options=["--identify", "CONST_SOME_COOL_VALUE"],
640 self.test_driver.check_cmd_and_json_cmd(
643 '[{{"full_name":"FbidMapField","pos":{{"filename":"{root}enum_1.php","line":3,"char_start":6,"char_end":17}},"kind":"enum"}}]'
645 options=["--identify", "FbidMapField"],
648 self.test_driver.check_cmd_and_json_cmd(
651 '[{{"full_name":"FbidMapField::FBID","pos":{{"filename":"{root}enum_1.php","line":4,"char_start":3,"char_end":6}},"kind":"const"}}]'
653 options=["--identify", "FbidMapField::FBID"],
656 self.test_driver.check_cmd_and_json_cmd(
659 '[{{"full_name":"f","pos":{{"filename":"{root}foo_1.php","line":3,"char_start":18,"char_end":18}},"kind":"function"}}]'
661 options=["--identify", "f"],
664 def test_ide_find_refs(self) -> None:
665 self.test_driver.start_hh_server()
667 self.test_driver.check_cmd_and_json_cmd(
669 "Foo",
670 'File "{root}foo_3.php", line 10, characters 17-19:',
671 "1 total results",
674 '[{{"name":"Foo","filename":"{root}foo_3.php",'
675 '"line":10,"char_start":17,"char_end":19}}]'
677 options=["--ide-find-refs", "1:20"],
678 stdin="<?hh function test(Foo $foo) { new Foo(); }",
681 def test_ide_highlight_refs(self) -> None:
682 self.test_driver.start_hh_server()
684 self.test_driver.check_cmd_and_json_cmd(
685 ["line 1, characters 20-22", "line 1, characters 36-38", "2 total results"],
687 '[{{"line":1,"char_start":20,"char_end":22}},'
688 '{{"line":1,"char_start":36,"char_end":38}}]'
690 options=["--ide-highlight-refs", "1:20"],
691 stdin="<?hh function test(Foo $foo) { new Foo(); }",
694 def test_search(self) -> None:
696 Test hh_client --search
699 self.test_driver.start_hh_server()
701 self.test_driver.check_cmd_and_json_cmd(
703 'File "{root}foo_3.php", line 9, characters 18-40: some_long_function_name, function'
706 '[{{"name":"some_long_function_name","filename":"{root}foo_3.php","desc":"function","line":9,"char_start":18,"char_end":40,"scope":""}}]'
708 options=["--search", "some_lo"],
711 def test_search_case_insensitive1(self) -> None:
713 Test that global search is not case sensitive
715 self.maxDiff = None
716 self.test_driver.start_hh_server()
718 self.test_driver.check_cmd(
720 'File "{root}foo_4.php", line 4, characters 10-24: '
721 "aaaaaaaaaaa_fun, function",
722 'File "{root}foo_4.php", line 3, characters 7-23: '
723 "Aaaaaaaaaaa_class, class",
725 options=["--search", "Aaaaaaaaaaa"],
728 def test_search_case_insensitive2(self) -> None:
730 Test that global search is not case sensitive
732 self.test_driver.start_hh_server()
734 self.test_driver.check_cmd(
736 'File "{root}foo_4.php", line 4, characters 10-24: '
737 "aaaaaaaaaaa_fun, function",
738 'File "{root}foo_4.php", line 3, characters 7-23: '
739 "Aaaaaaaaaaa_class, class",
741 options=["--search", "aaaaaaaaaaa"],
744 def test_auto_complete(self) -> None:
746 Test hh_client --auto-complete
749 self.test_driver.start_hh_server()
751 self.test_driver.check_cmd_and_json_cmd(
752 ["some_long_function_name (function(): _)"],
754 # test the --json output because the non-json one doesn't contain
755 # the filename, and we are especially interested in testing file
756 # paths
757 # the doubled curly braces are because this string gets passed
758 # through format()
759 '[{{"name":"some_long_function_name",'
760 '"type":"(function(): _)",'
761 '"pos":{{"filename":"{root}foo_3.php",'
762 '"line":9,"char_start":18,"char_end":40}},'
763 '"func_details":{{"min_arity":0,"return_type":"_","params":[]}},'
764 '"expected_ty":false}}]'
766 options=["--auto-complete"],
767 stdin="<?hh function f() { some_AUTO332\n",
770 def test_list_files(self) -> None:
772 Test hh_client --list-files
774 os.remove(os.path.join(self.test_driver.repo_dir, "foo_2.php"))
775 self.test_driver.start_hh_server(changed_files=["foo_2.php"])
776 self.test_driver.check_cmd_and_json_cmd(
777 ["{root}foo_1.php"],
778 ["{root}foo_1.php"], # see comment for identify-function
779 options=["--list-files"],
782 def test_type_at_pos(self) -> None:
784 Test hh_client --type-at-pos
786 self.test_driver.start_hh_server()
788 self.test_driver.check_cmd_and_json_cmd(
789 ["string"],
791 '{{"type":"string",'
792 + '"pos":{{"filename":"","line":0,"char_start":0,"char_end":0}},'
793 + '"full_type":{{"src_pos":{{"filename":"{root}foo_3.php","line":3,"char_start":23,"char_end":28}},"kind":"primitive","name":"string"}}}}'
795 options=["--type-at-pos", "{root}foo_3.php:11:14"],
798 def test_type_at_pos_batch(self) -> None:
800 Test hh_client --type-at-pos-batch
802 self.test_driver.start_hh_server()
804 self.test_driver.check_cmd(
806 '{{"position":'
807 + '{{"file":"{root}foo_3.php",'
808 + '"line":11,'
809 + '"character":14}}'
810 + ',"type":{{'
811 + '"src_pos":{{"filename":"{root}foo_3.php","line":3,"char_start":23,"char_end":28}},'
812 + '"kind":"primitive",'
813 + '"name":"string"}}}}'
815 options=["--type-at-pos-batch", "{root}foo_3.php:11:14"],
818 def test_ide_get_definition(self) -> None:
820 Test hh_client --ide-get-definition
822 self.test_driver.start_hh_server()
824 self.test_driver.check_cmd_and_json_cmd(
826 "name: \\bar, kind: function, span: line 1, characters 42-44,"
827 " is_declaration: false",
828 "definition:",
829 " bar",
830 " kind: function",
831 " id: function::bar",
832 ' position: File "", line 1, characters 15-17:',
833 ' span: File "", line 1, character 6 - line 1, character 22:',
834 " modifiers: ",
835 " params:",
840 '[{{"name":"\\\\bar","result_type":"function",'
841 '"pos":{{"filename":"","line":1,"char_start":42,"char_end":44}},'
842 '"definition_pos":{{"filename":"","line":1,"char_start":15,'
843 '"char_end":17}},"definition_span":{{"filename":"","line_start":1,'
844 '"char_start":6,"line_end":1,"char_end":22}},'
845 '"definition_id":"function::bar"}}]'
847 options=["--ide-get-definition", "1:43"],
848 stdin="<?hh function bar() {} function test() { bar() }",
851 def test_ide_outline(self) -> None:
853 Test hh_client --ide-outline
855 self.test_driver.start_hh_server()
858 This call is here to ensure that server is running. Outline command
859 doesn't require it to be, but integration test suite assumes it and
860 checks it's state after each test.
862 self.test_driver.check_cmd(["No errors!"])
864 self.test_driver.check_cmd_and_json_cmd(
866 "bar",
867 " kind: function",
868 " id: function::bar",
869 ' position: File "", line 1, characters 15-17:',
870 ' span: File "", line 1, character 6 - line 1, character 22:',
871 " modifiers: ",
872 " params:",
876 '[{{"kind":"function","name":"bar","id":"function::bar",'
877 '"position":{{"filename":"",'
878 '"line":1,"char_start":15,"char_end":17}},"span":'
879 '{{"filename":"","line_start":1,"char_start":6,"line_end":1,'
880 '"char_end":22}},"modifiers":[],"params":[]}}]'
882 options=["--ide-outline"],
883 stdin="<?hh function bar() {}",
886 def test_ide_get_definition_multi_file(self) -> None:
888 Test hh_client --ide-get-definition when definition we look for is
889 in file different from input file
891 self.test_driver.start_hh_server()
893 self.test_driver.check_cmd_and_json_cmd(
895 "name: \\ClassToBeIdentified::methodToBeIdentified, kind: method,"
896 " span: line 1, characters 45-64, is_declaration: false",
897 "definition:",
898 " methodToBeIdentified",
899 " kind: method",
900 " id: method::ClassToBeIdentified::methodToBeIdentified",
901 ' position: File "{root}foo_5.php", line 4, characters 26-45:',
902 ' span: File "{root}foo_5.php", line 4, character 3 - line 4,'
903 " character 50:",
904 " modifiers: public static ",
905 " params:",
910 '[{{"name":"\\\\ClassToBeIdentified::methodToBeIdentified",'
911 '"result_type":"method","pos":{{"filename":"","line":1,'
912 '"char_start":45,"char_end":64}},"definition_pos":'
913 '{{"filename":"{root}foo_5.php","line":4,"char_start":26,'
914 '"char_end":45}},"definition_span":{{"filename":"{root}foo_5.php",'
915 '"line_start":4,"char_start":3,"line_end":4,"char_end":50}},'
916 '"definition_id":'
917 '"method::ClassToBeIdentified::methodToBeIdentified"}}]'
919 options=["--ide-get-definition", "1:50"],
920 stdin="<?hh function test() { "
921 "ClassToBeIdentified::methodToBeIdentified () }",
924 def test_abnormal_typechecker_exit_message(self) -> None:
926 Tests that the monitor outputs a useful message when its typechecker
927 exits abnormally.
930 self.test_driver.start_hh_server()
931 monitor_logs = self.test_driver.get_monitor_logs()
932 m = re.search(
933 "Just started typechecker server with pid: ([0-9]+)", monitor_logs
935 self.assertIsNotNone(m)
936 assert m is not None, "for mypy"
937 pid = m.group(1)
938 self.assertIsNotNone(pid)
939 os.kill(int(pid), signal.SIGTERM)
940 # For some reason, waitpid in the monitor after the kill signal
941 # sent above doesn't preserve ordering - maybe because they're
942 # in separate processes? Give it some time.
943 time.sleep(1)
944 client_error = self.test_driver.check_cmd(
945 expected_output=None, assert_loaded_saved_state=False
947 self.assertIn("Last server killed by signal", client_error)
949 def test_duplicate_parent(self) -> None:
951 This checks that we handle duplicate parent classes, i.e. when Bar
952 extends Foo and there are two declarations of Foo. We want to make sure
953 that when the duplicate gets removed, we recover correctly by
954 redeclaring Bar with the remaining parent class.
956 with open(os.path.join(self.test_driver.repo_dir, "foo_4.php"), "w") as f:
957 f.write(
958 """<?hh //partial
960 class Foo { // also declared in foo_3.php in setUpClass
961 public static $x;
965 with open(os.path.join(self.test_driver.repo_dir, "foo_5.php"), "w") as f:
966 f.write(
967 """<?hh //partial
969 class Bar extends Foo {}
971 function main(Bar $a) {
972 return $a::$y;
976 self.test_driver.start_hh_server(changed_files=["foo_4.php", "foo_5.php"])
977 self.test_driver.check_cmd(
979 "{root}foo_4.php:3:19,21: Name already bound: `Foo` (Naming[2012])",
980 " {root}foo_3.php:7:15,17: Previous definition is here",
981 "{root}foo_5.php:6:28,29: No class variable `$y` in `Bar` (Typing[4090])",
982 " {root}foo_4.php:4:31,32: Did you mean `$x` instead?",
983 " {root}foo_5.php:3:19,21: Declaration of `Bar` is here",
987 os.remove(os.path.join(self.test_driver.repo_dir, "foo_4.php"))
988 self.test_driver.check_cmd(
990 "{root}foo_5.php:6:28,29: No class variable `$y` in `Bar` (Typing[4090])",
991 " {root}foo_5.php:3:19,21: Declaration of `Bar` is here",
995 with open(os.path.join(self.test_driver.repo_dir, "foo_4.php"), "w") as f:
996 f.write(
997 """<?hh //partial
999 class Foo {
1000 public static $y;
1004 os.remove(os.path.join(self.test_driver.repo_dir, "foo_3.php"))
1005 self.test_driver.check_cmd(["No errors!"])
1007 def test_refactor_methods(self) -> None:
1008 with open(os.path.join(self.test_driver.repo_dir, "foo_4.php"), "w") as f:
1009 f.write(
1010 """<?hh //partial
1012 class Bar extends Foo {
1013 public function f() {}
1014 public function g() {}
1017 class Baz extends Bar {
1018 public function g() {
1019 $this->f();
1024 self.test_driver.start_hh_server(changed_files=["foo_4.php"])
1026 self.test_driver.check_cmd_and_json_cmd(
1027 ["Rewrote 1 files."],
1029 '[{{"filename":"{root}foo_4.php","patches":[{{'
1030 '"char_start":84,"char_end":85,"line":4,"col_start":33,'
1031 '"col_end":33,"patch_type":"replace","replacement":"wat"}},'
1032 '{{"char_start":246,"char_end":247,"line":10,"col_start":28,'
1033 '"col_end":28,"patch_type":"replace","replacement":"wat"}}]}}]'
1035 options=["--refactor", "Method", "Bar::f", "Bar::wat"],
1037 self.test_driver.check_cmd_and_json_cmd(
1038 ["Rewrote 1 files."],
1040 '[{{"filename":"{root}foo_4.php","patches":[{{'
1041 '"char_start":125,"char_end":126,"line":5,"col_start":33,'
1042 '"col_end":33,"patch_type":"replace",'
1043 '"replacement":"overrideMe"}},{{"char_start":215,'
1044 '"char_end":216,"line":9,"col_start":33,"col_end":33,'
1045 '"patch_type":"replace","replacement":"overrideMe"}}]}}]'
1047 options=["--refactor", "Method", "Bar::g", "Bar::overrideMe"],
1049 self.test_driver.check_cmd_and_json_cmd(
1050 ["Rewrote 2 files."],
1052 '[{{"filename":"{root}foo_4.php","patches":[{{'
1053 '"char_start":46,"char_end":49,"line":3,"col_start":31,'
1054 '"col_end":33,"patch_type":"replace","replacement":"Qux"}}]}},'
1055 '{{"filename":"{root}foo_3.php","patches":[{{'
1056 '"char_start":96,"char_end":99,"line":7,"col_start":15,'
1057 '"col_end":17,"patch_type":"replace","replacement":"Qux"}},'
1058 '{{"char_start":165,"char_end":168,"line":10,"col_start":17,'
1059 '"col_end":19,"patch_type":"replace","replacement":"Qux"}}]'
1060 "}}]"
1062 options=["--refactor", "Class", "Foo", "Qux"],
1065 with open(os.path.join(self.test_driver.repo_dir, "foo_4.php")) as f:
1066 out = f.read()
1067 self.assertEqual(
1068 out,
1069 """<?hh //partial
1071 class Bar extends Qux {
1072 public function wat() {}
1073 public function overrideMe() {}
1076 class Baz extends Bar {
1077 public function overrideMe() {
1078 $this->wat();
1081 """,
1084 with open(os.path.join(self.test_driver.repo_dir, "foo_3.php")) as f:
1085 out = f.read()
1086 self.assertEqual(
1087 out,
1088 """<?hh //partial
1090 function h(): string {
1091 return "a";
1094 class Qux {}
1096 function some_long_function_name() {
1097 new Qux();
1098 h();
1100 """,
1103 def test_refactor_functions(self) -> None:
1104 with open(os.path.join(self.test_driver.repo_dir, "foo_4.php"), "w") as f:
1105 f.write(
1106 """<?hh //partial
1108 function wow() {
1109 wat();
1110 return f();
1113 function wat() {}
1116 self.test_driver.start_hh_server(changed_files=["foo_4.php"])
1118 self.test_driver.check_cmd_and_json_cmd(
1119 ["Rewrote 1 files."],
1121 '[{{"filename":"{root}foo_4.php","patches":[{{'
1122 '"char_start":132,"char_end":135,"line":8,"col_start":22,'
1123 '"col_end":24,"patch_type":"replace","replacement":"woah"}},'
1124 '{{"char_start":61,"char_end":64,"line":4,"col_start":17,'
1125 '"col_end":19,"patch_type":"replace","replacement":"woah"}}]'
1126 "}}]"
1128 options=["--refactor", "Function", "wat", "woah"],
1130 self.test_driver.check_cmd_and_json_cmd(
1131 ["Rewrote 2 files."],
1133 '[{{"filename":"{root}foo_4.php","patches":[{{'
1134 '"char_start":92,"char_end":93,"line":5,"col_start":24,'
1135 '"col_end":24,"patch_type":"replace","replacement":"fff"}}]}},'
1136 '{{"filename":"{root}foo_1.php","patches":[{{'
1137 '"char_start":33,"char_end":34,"line":3,"col_start":18,'
1138 '"col_end":18,"patch_type":"replace","replacement":"fff"}}]'
1139 "}}]"
1141 options=["--refactor", "Function", "f", "fff"],
1144 with open(os.path.join(self.test_driver.repo_dir, "foo_4.php")) as f:
1145 out = f.read()
1146 self.assertEqual(
1147 out,
1148 """<?hh //partial
1150 function wow() {
1151 woah();
1152 return fff();
1155 function woah() {}
1156 """,
1159 with open(os.path.join(self.test_driver.repo_dir, "foo_1.php")) as f:
1160 out = f.read()
1161 self.assertEqual(
1162 out,
1163 """<?hh //partial
1165 function fff() {
1166 return g() + 1;
1168 """,
1171 def test_refactor_typedefs(self) -> None:
1172 with open(os.path.join(self.test_driver.repo_dir, "foo_4.php"), "w") as f:
1173 f.write(
1174 """<?hh //partial
1176 newtype NewType = int;
1177 type Type = int;
1179 class MyClass {
1180 public function myFunc(Type $x): NewType {
1181 return $x;
1186 self.test_driver.start_hh_server(changed_files=["foo_4.php"])
1188 self.test_driver.check_cmd_and_json_cmd(
1189 ["Rewrote 1 files."],
1191 '[{{"filename":"{root}foo_4.php","patches":[{{'
1192 '"char_start":36,"char_end":43,"line":3,"col_start":21,'
1193 '"col_end":27,"patch_type":"replace","replacement":"NewTypeX"}},'
1194 '{{"char_start":158,"char_end":165,"line":7,"col_start":50,'
1195 '"col_end":56,"patch_type":"replace","replacement":"NewTypeX"}}]'
1196 "}}]"
1198 options=["--refactor", "Class", "NewType", "NewTypeX"],
1201 self.test_driver.check_cmd_and_json_cmd(
1202 ["Rewrote 1 files."],
1204 '[{{"filename":"{root}foo_4.php","patches":[{{'
1205 '"char_start":69,"char_end":73,"line":4,"col_start":18,'
1206 '"col_end":21,"patch_type":"replace","replacement":"TypeX"}},'
1207 '{{"char_start":149,"char_end":153,"line":7,"col_start":40,'
1208 '"col_end":43,"patch_type":"replace","replacement":"TypeX"}}]'
1209 "}}]"
1211 options=["--refactor", "Class", "Type", "TypeX"],
1214 with open(os.path.join(self.test_driver.repo_dir, "foo_4.php")) as f:
1215 out = f.read()
1216 self.assertEqual(
1217 out,
1218 """<?hh //partial
1220 newtype NewTypeX = int;
1221 type TypeX = int;
1223 class MyClass {
1224 public function myFunc(TypeX $x): NewTypeX {
1225 return $x;
1228 """,
1231 def test_auto_namespace_alias_addition(self) -> None:
1233 Add namespace alias and check if it is still good
1236 self.test_driver.start_hh_server()
1237 self.test_driver.check_cmd(["No errors!"])
1239 with open(os.path.join(self.test_driver.repo_dir, "auto_ns_2.php"), "w") as f:
1240 f.write(
1241 """<?hh //partial
1243 function haha() {
1244 Herp\\f();
1245 return 1;
1250 self.test_driver.check_cmd(["No errors!"])
1252 def test_interrupt(self) -> None:
1253 # filesystem interruptions are only triggered by Watchman
1254 with open(os.path.join(self.test_driver.repo_dir, ".watchmanconfig"), "w") as f:
1255 f.write("{}")
1256 with open(os.path.join(self.test_driver.repo_dir, "hh.conf"), "a") as f:
1257 f.write(
1258 "use_watchman = true\n"
1259 + "interrupt_on_watchman = true\n"
1260 + "interrupt_on_client = true\n"
1261 + "watchman_subscribe_v2 = true\n"
1264 self.test_driver.start_hh_server()
1265 self.test_driver.start_hh_loop_forever_assert_timeout()
1266 self.test_driver.check_cmd(
1267 ["string"], options=["--type-at-pos", "{root}foo_3.php:11:14"]
1269 self.test_driver.stop_hh_loop_forever()
1271 def test_status_single(self) -> None:
1273 Test hh_client check --single
1275 self.test_driver.start_hh_server()
1277 with open(
1278 os.path.join(self.test_driver.repo_dir, "typing_error.php"), "w"
1279 ) as f:
1280 f.write("<?hh //strict\n function aaaa(): int { return h(); }")
1282 self.test_driver.check_cmd(
1284 "{root}typing_error.php:2:32,34: Invalid return type (Typing[4110])",
1285 " {root}typing_error.php:2:19,21: Expected `int`",
1286 " {root}foo_3.php:3:23,28: But got `string`",
1288 options=["--single", "{root}typing_error.php"],
1289 stdin="",
1292 self.test_driver.check_cmd(
1294 ":2:32,34: Invalid return type (Typing[4110])",
1295 " :2:19,21: Expected `int`",
1296 " {root}foo_3.php:3:23,28: But got `string`",
1298 options=["--single", "-"],
1299 stdin="<?hh //strict\n function aaaa(): int { return h(); }",
1302 def test_lint_xcontroller(self) -> None:
1303 self.test_driver.start_hh_server()
1305 with open(os.path.join(self.test_driver.repo_dir, "in_list.txt"), "w") as f:
1306 f.write(os.path.join(self.test_driver.repo_dir, "xcontroller.php"))
1308 with open(os.path.join(self.test_driver.repo_dir, "xcontroller.php"), "w") as f:
1309 f.write(
1310 "<?hh\n class MyXController extends XControllerBase { "
1311 "public function getPath() { return f(); } }"
1314 self.test_driver.check_cmd(
1316 'File "{root}xcontroller.php", line 2, characters 8-20:',
1317 "When linting MyXController: The body of isDelegateOnly should "
1318 "only contain `return true;` or `return false;` (Lint[5615])",
1319 'File "{root}xcontroller.php", line 2, characters 8-20:',
1320 "When linting MyXController: getPath method of MyXController must "
1321 "be present and return a static literal for build purposes (Lint[5615])",
1323 options=["--lint-xcontroller", "{root}in_list.txt"],
1326 def test_incremental_typecheck_same_file(self) -> None:
1327 self.maxDiff = None
1328 self.test_driver.start_hh_server()
1330 # Important: typecheck the file after creation but before adding contents
1331 # to test forward naming table updating.
1332 open(
1333 os.path.join(
1334 self.test_driver.repo_dir, "test_incremental_typecheck_same_file.php"
1336 "w",
1337 ).close()
1338 self.test_driver.check_cmd(["No errors!"])
1340 with open(
1341 os.path.join(
1342 self.test_driver.repo_dir, "test_incremental_typecheck_same_file.php"
1344 "w",
1345 ) as f:
1346 f.write(
1347 """<?hh // strict
1349 // test_incremental_typecheck_same_file
1350 class TestIncrementalTypecheckSameFile {}
1353 self.test_driver.check_cmd(["No errors!"])
1355 # Notice how the only change is the removed doc block.
1356 with open(
1357 os.path.join(
1358 self.test_driver.repo_dir, "test_incremental_typecheck_same_file.php"
1360 "w",
1361 ) as f:
1362 f.write(
1363 """<?hh // strict
1365 class TestIncrementalTypecheckSameFile {}
1368 self.test_driver.check_cmd(["No errors!"])