|
1 | 1 | """Cmd2 unit/functional testing""" |
2 | 2 |
|
| 3 | +import asyncio |
3 | 4 | import contextlib |
| 5 | +import contextvars |
4 | 6 | import io |
5 | 7 | import os |
6 | 8 | import signal |
|
17 | 19 | from unittest import mock |
18 | 20 |
|
19 | 21 | import pytest |
20 | | -from prompt_toolkit.application import create_app_session, get_app |
| 22 | +from prompt_toolkit.application import Application, create_app_session, get_app |
| 23 | +from prompt_toolkit.application.current import set_app |
21 | 24 | from prompt_toolkit.auto_suggest import AutoSuggestFromHistory |
22 | 25 | from prompt_toolkit.completion import DummyCompleter |
| 26 | +from prompt_toolkit.data_structures import Size |
23 | 27 | from prompt_toolkit.input import DummyInput, create_pipe_input |
24 | 28 | from prompt_toolkit.output import DummyOutput |
| 29 | +from prompt_toolkit.output.vt100 import Vt100_Output |
25 | 30 | from prompt_toolkit.shortcuts import CompleteStyle, PromptSession |
26 | 31 | from rich.style import Style |
27 | 32 | from rich.text import Text |
@@ -4965,6 +4970,153 @@ def get_rprompt(self): |
4965 | 4970 | assert True not in drawn_while_done, "the right prompt was drawn into the committed final frame" |
4966 | 4971 |
|
4967 | 4972 |
|
| 4973 | +def test_set_window_title_is_flushed_during_a_command() -> None: |
| 4974 | + stream = io.StringIO() |
| 4975 | + output = Vt100_Output(stream, lambda: Size(rows=24, columns=80), enable_cpr=False) |
| 4976 | + observed = [] |
| 4977 | + |
| 4978 | + class TitleApp(cmd2.Cmd): |
| 4979 | + def do_work(self, _statement): |
| 4980 | + self.set_window_title("Working…") |
| 4981 | + observed.append(stream.getvalue()) |
| 4982 | + |
| 4983 | + with create_app_session(input=DummyInput(), output=output): |
| 4984 | + app = TitleApp(allow_cli_args=False) |
| 4985 | + app.onecmd_plus_hooks("work") |
| 4986 | + |
| 4987 | + assert observed == ["\x1b]2;Working…\x07"] |
| 4988 | + |
| 4989 | + |
| 4990 | +@pytest.mark.parametrize("from_worker", [False, True]) |
| 4991 | +def test_set_window_title_while_a_prompt_is_running(from_worker) -> None: |
| 4992 | + stream = io.StringIO() |
| 4993 | + title_threads = [] |
| 4994 | + observed = [] |
| 4995 | + workers = [] |
| 4996 | + loop_thread = threading.get_ident() |
| 4997 | + |
| 4998 | + class TitleOutput(Vt100_Output): |
| 4999 | + def set_title(self, title): |
| 5000 | + title_threads.append(threading.get_ident()) |
| 5001 | + super().set_title(title) |
| 5002 | + |
| 5003 | + output = TitleOutput(stream, lambda: Size(rows=24, columns=80), enable_cpr=False) |
| 5004 | + with create_pipe_input() as pipe_input, create_app_session(input=pipe_input, output=output): |
| 5005 | + app = Application(input=pipe_input, output=output) |
| 5006 | + sent = False |
| 5007 | + |
| 5008 | + def check_title(): |
| 5009 | + # Observe before exiting, because the final render also flushes output. |
| 5010 | + observed.append("\x1b]2;Working…\x07" in stream.getvalue()) |
| 5011 | + app.exit() |
| 5012 | + |
| 5013 | + def update_title(): |
| 5014 | + cmd2.Cmd.set_window_title("Working…") |
| 5015 | + app.loop.call_soon_threadsafe(check_title) |
| 5016 | + |
| 5017 | + def after_render(_sender): |
| 5018 | + nonlocal sent |
| 5019 | + if sent: |
| 5020 | + return |
| 5021 | + sent = True |
| 5022 | + # Start after the first frame, so its flush cannot hide a buffered title. |
| 5023 | + if from_worker: |
| 5024 | + context = contextvars.copy_context() |
| 5025 | + worker = threading.Thread(target=context.run, args=(update_title,), daemon=True) |
| 5026 | + workers.append(worker) |
| 5027 | + worker.start() |
| 5028 | + else: |
| 5029 | + update_title() |
| 5030 | + |
| 5031 | + app.after_render += after_render |
| 5032 | + app.run(pre_run=lambda: app.loop.call_later(5, app.exit), set_exception_handler=False) |
| 5033 | + |
| 5034 | + for worker in workers: |
| 5035 | + worker.join(timeout=5) |
| 5036 | + assert not worker.is_alive() |
| 5037 | + |
| 5038 | + assert observed == [True] |
| 5039 | + assert title_threads == [loop_thread] |
| 5040 | + |
| 5041 | + |
| 5042 | +def test_set_window_title_without_a_terminal() -> None: |
| 5043 | + with create_app_session(input=DummyInput(), output=DummyOutput()): |
| 5044 | + cmd2.Cmd.set_window_title("Working…") |
| 5045 | + |
| 5046 | + |
| 5047 | +def test_set_window_title_after_the_application_loop_has_closed() -> None: |
| 5048 | + stream = io.StringIO() |
| 5049 | + output = Vt100_Output(stream, lambda: Size(rows=24, columns=80), enable_cpr=False) |
| 5050 | + app = Application(input=DummyInput(), output=output) |
| 5051 | + output.flush() |
| 5052 | + stream.seek(0) |
| 5053 | + stream.truncate() |
| 5054 | + loop = asyncio.new_event_loop() |
| 5055 | + loop.close() |
| 5056 | + app.loop = loop |
| 5057 | + |
| 5058 | + with ( |
| 5059 | + create_app_session(input=DummyInput(), output=output), |
| 5060 | + set_app(app), |
| 5061 | + mock.patch.object(output, "flush", wraps=output.flush) as flush, |
| 5062 | + ): |
| 5063 | + cmd2.Cmd.set_window_title("Finished") |
| 5064 | + flush.assert_called_once_with() |
| 5065 | + |
| 5066 | + assert stream.getvalue() == "\x1b]2;Finished\x07" |
| 5067 | + |
| 5068 | + |
| 5069 | +@pytest.mark.parametrize("close_during_scheduling", [True, False]) |
| 5070 | +def test_set_window_title_when_scheduling_fails(monkeypatch, close_during_scheduling) -> None: |
| 5071 | + stream = io.StringIO() |
| 5072 | + output = Vt100_Output(stream, lambda: Size(rows=24, columns=80), enable_cpr=False) |
| 5073 | + app = Application(input=DummyInput(), output=output) |
| 5074 | + output.flush() |
| 5075 | + stream.seek(0) |
| 5076 | + stream.truncate() |
| 5077 | + loop = asyncio.new_event_loop() |
| 5078 | + app.loop = loop |
| 5079 | + schedule = loop.call_soon_threadsafe |
| 5080 | + ready = threading.Event() |
| 5081 | + loop.call_soon(ready.set) |
| 5082 | + worker = threading.Thread(target=loop.run_forever, daemon=True) |
| 5083 | + |
| 5084 | + def fail_to_schedule(callback): |
| 5085 | + if not close_during_scheduling: |
| 5086 | + raise RuntimeError("scheduling failed") |
| 5087 | + # Close the real loop after set_window_title has observed it running. |
| 5088 | + schedule(loop.stop) |
| 5089 | + worker.join(timeout=5) |
| 5090 | + assert not worker.is_alive() |
| 5091 | + loop.close() |
| 5092 | + schedule(callback) |
| 5093 | + |
| 5094 | + worker.start() |
| 5095 | + try: |
| 5096 | + assert ready.wait(timeout=5) |
| 5097 | + monkeypatch.setattr(loop, "call_soon_threadsafe", fail_to_schedule) |
| 5098 | + with ( |
| 5099 | + create_app_session(input=DummyInput(), output=output), |
| 5100 | + set_app(app), |
| 5101 | + mock.patch.object(output, "flush", wraps=output.flush) as flush, |
| 5102 | + ): |
| 5103 | + if close_during_scheduling: |
| 5104 | + cmd2.Cmd.set_window_title("Finished") |
| 5105 | + flush.assert_called_once_with() |
| 5106 | + assert stream.getvalue() == "\x1b]2;Finished\x07" |
| 5107 | + else: |
| 5108 | + with pytest.raises(RuntimeError, match="scheduling failed"): |
| 5109 | + cmd2.Cmd.set_window_title("Finished") |
| 5110 | + flush.assert_not_called() |
| 5111 | + assert stream.getvalue() == "" |
| 5112 | + finally: |
| 5113 | + if loop.is_running(): |
| 5114 | + schedule(loop.stop) |
| 5115 | + worker.join(timeout=5) |
| 5116 | + assert not worker.is_alive() |
| 5117 | + loop.close() |
| 5118 | + |
| 5119 | + |
4968 | 5120 | def test_get_bottom_toolbar(base_app: cmd2.Cmd) -> None: |
4969 | 5121 | assert base_app.get_bottom_toolbar() is None |
4970 | 5122 |
|
|
0 commit comments