diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 86b5d940..d710273c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -52,7 +52,7 @@ jobs: - name: Integration tests (GITLINT_QA_USE_SH_LIB=0) run: | - hatch run qa:integration-tests --ignore qa/test_hooks.py qa + hatch run qa:integration-tests -k "not(test_commit_hook_continue or test_commit_hook_abort or test_commit_hook_edit)" qa env: GITLINT_QA_USE_SH_LIB: 0 @@ -134,7 +134,7 @@ jobs: - name: Integration tests run: | hatch run qa:install-local - hatch run qa:integration-tests -k "not (HookTests or test_lint_staged_stdin or test_stdin_file or test_stdin_pipe_empty)" qa + hatch run qa:integration-tests -k "not (test_commit_hook_continue or test_commit_hook_abort or test_commit_hook_edit or test_lint_staged_stdin or test_stdin_file or test_stdin_pipe_empty)" qa - name: Build test (gitlint) run: | diff --git a/qa/shell.py b/qa/shell.py index 7cddf3bc..ed0f043d 100644 --- a/qa/shell.py +++ b/qa/shell.py @@ -1,6 +1,8 @@ # This code is mostly duplicated from the `gitlint.shell` module. We consciously duplicate this code as to not depend # on gitlint internals for our integration testing framework. +import asyncio +import queue import subprocess from qa.utils import USE_SH_LIB, DEFAULT_ENCODING @@ -75,27 +77,77 @@ def run_command(command, *args, **kwargs): def _exec(*args, **kwargs): popen_kwargs = { - "stdout": subprocess.PIPE, - "stderr": subprocess.PIPE, - "stdin": subprocess.PIPE, + "stdout": asyncio.subprocess.PIPE, + "stderr": asyncio.subprocess.PIPE, + "stdin": asyncio.subprocess.PIPE, "shell": kwargs.get("_tty_out", False), "cwd": kwargs.get("_cwd", None), "env": kwargs.get("_env", None), } - stdin_input = None + stdin = None if len(args) > 1 and isinstance(args[1], ShResult): - stdin_input = args[1].stdout + stdin = args[1].stdout # pop args[1] from the array and use it as stdin args = list(args) args.pop(1) - popen_kwargs["stdin"] = subprocess.PIPE try: - with subprocess.Popen(args, **popen_kwargs) as p: - if stdin_input: - result = p.communicate(stdin_input) - else: + + async def read_stream(p, stream, iofunc, q, timeout=0.3): + line = bytearray() + try: + while True: + char = await asyncio.wait_for(stream.read(1), timeout) + line += bytearray(char) + if char == b"\n": + decoded = line.decode(DEFAULT_ENCODING) + iofunc(decoded, q) + line = bytearray() + except asyncio.TimeoutError: + decoded = line.decode(DEFAULT_ENCODING) + iofunc(decoded, q) + + async def write_stdin(p, q): + # inputstr = await q.get() + inputstr = await asyncio.wait_for(q.get(), 0.25) + p.stdin.write(inputstr.encode(DEFAULT_ENCODING)) + await p.stdin.drain() + + if kwargs.get("_out", None): + # redirect stderr to stdout (this will ensure we capture the last git output lines which are printed to stdout, not stderr) + popen_kwargs["stderr"] = asyncio.subprocess.STDOUT + + async def start_process(): + p = await asyncio.create_subprocess_exec(*args, **popen_kwargs) + + q = asyncio.Queue() + + await asyncio.gather( + # read_stream(p, p.stderr, kwargs["_out"], q), + read_stream(p, p.stdout, kwargs["_out"], q), + ) + print("after gather1") + await asyncio.gather(write_stdin(p, q)) + print("after gather2") + + # Manually answer the prompt here, for some reason I can't get this to work via stdin + await asyncio.sleep(2) + + await asyncio.gather( + read_stream(p, p.stdout, kwargs["_out"], q), + ) + print("after gather 3") + await p.wait() + print("process finished") + + asyncio.run(start_process()) + return + elif stdin: + with subprocess.Popen(args, **popen_kwargs) as p: + result = p.communicate(stdin) + else: + with subprocess.Popen(args, **popen_kwargs) as p: result = p.communicate() except FileNotFoundError as exc: diff --git a/qa/test_hooks.py b/qa/test_hooks.py index 19edeb25..a9fbe5d7 100644 --- a/qa/test_hooks.py +++ b/qa/test_hooks.py @@ -30,18 +30,16 @@ def setUp(self): # install git commit-msg hook and assert output output_installed = gitlint("install-hook", _cwd=self.tmp_git_repo) - expected_installed = ( - f"Successfully installed gitlint commit-msg hook in {self.tmp_git_repo}/.git/hooks/commit-msg\n" - ) + commit_msg_hook_path = os.path.join(self.tmp_git_repo, ".git", "hooks", "commit-msg") + expected_installed = f"Successfully installed gitlint commit-msg hook in {commit_msg_hook_path}\n" self.assertEqualStdout(output_installed, expected_installed) def tearDown(self): # uninstall git commit-msg hook and assert output output_uninstalled = gitlint("uninstall-hook", _cwd=self.tmp_git_repo) - expected_uninstalled = ( - f"Successfully uninstalled gitlint commit-msg hook from {self.tmp_git_repo}/.git/hooks/commit-msg\n" - ) + commit_msg_hook_path = os.path.join(self.tmp_git_repo, ".git", "hooks", "commit-msg") + expected_uninstalled = f"Successfully uninstalled gitlint commit-msg hook from {commit_msg_hook_path}\n" self.assertEqualStdout(output_uninstalled, expected_uninstalled) super().tearDown() @@ -54,10 +52,13 @@ def _violations(self): def _interact(self, line, stdin): self.githook_output.append(line) + print(line) # Answer 'yes' to question to keep violating commit-msg if "Your commit message contains violations" in line: + print("VIOLATIONS FOUND!") response = self.responses[self.response_index] - stdin.put(f"{response}\n") + stdin.put_nowait(f"{response}\r\n") + print("AFTER PUT") self.response_index = (self.response_index + 1) % len(self.responses) def test_commit_hook_no_violations(self): @@ -93,6 +94,10 @@ def test_commit_hook_continue(self): " 1 file changed, 0 insertions(+), 0 deletions(-)\n", f" create mode 100644 {test_filename}\n", ] + print("EXPECTED OUTPUT") + print(expected_output) + print("ACTUAL OUTPUT") + print(self.githook_output) for output, expected in zip(self.githook_output, expected_output): self.assertMultiLineEqual(output.replace("\r", ""), expected.replace("\r", "")) @@ -171,10 +176,10 @@ def test_commit_hook_worktree(self): output_installed = gitlint("install-hook", _cwd=worktree_dir) expected_hook_path = os.path.join(tmp_git_repo, ".git", "hooks", "commit-msg") - expected_msg = f"Successfully installed gitlint commit-msg hook in {expected_hook_path}\r\n" - self.assertEqual(output_installed, expected_msg) + expected_msg = f"Successfully installed gitlint commit-msg hook in {expected_hook_path}\n" + self.assertEqualStdout(output_installed, expected_msg) output_uninstalled = gitlint("uninstall-hook", _cwd=worktree_dir) expected_hook_path = os.path.join(tmp_git_repo, ".git", "hooks", "commit-msg") - expected_msg = f"Successfully uninstalled gitlint commit-msg hook from {expected_hook_path}\r\n" - self.assertEqual(output_uninstalled, expected_msg) + expected_msg = f"Successfully uninstalled gitlint commit-msg hook from {expected_hook_path}\n" + self.assertEqualStdout(output_uninstalled, expected_msg)