From 344a97e08a695af40ec77e439582ffdc06154f21 Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Fri, 19 Apr 2024 16:01:48 +0200 Subject: [PATCH 1/2] Add meson test --interactive This is very similar to --gdb, except it doesn't spawn GDB, but connects stdin/stdout/stderr directly to the test itself. This allows interacting with integration tests that spawn a shell in a container or virtual machine when the test fails. In systemd we're migrating our integration tests to run using the meson test runner. We want to allow interactive debugging of failed tests directly in the virtual machine or container that is spawned to run the test. To make this possible, we need meson test to connect stdin/stdout/stderr of the test directly to the user's terminal, just like is done with the --gdb option. --- data/shell-completions/bash/meson | 1 + data/shell-completions/zsh/_meson | 1 + docs/markdown/Unit-tests.md | 10 +++++++ docs/markdown/snippets/test_interactive.md | 6 ++++ mesonbuild/mtest.py | 32 +++++++++++++--------- 5 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 docs/markdown/snippets/test_interactive.md diff --git a/data/shell-completions/bash/meson b/data/shell-completions/bash/meson index 55c9c008d98c..dc437f10fd66 100644 --- a/data/shell-completions/bash/meson +++ b/data/shell-completions/bash/meson @@ -566,6 +566,7 @@ _meson-test() { no-rebuild gdb gdb-path + interactive list wrapper suite diff --git a/data/shell-completions/zsh/_meson b/data/shell-completions/zsh/_meson index e6f50f1af923..402539f1ba3a 100644 --- a/data/shell-completions/zsh/_meson +++ b/data/shell-completions/zsh/_meson @@ -181,6 +181,7 @@ local -a meson_commands=( '--no-rebuild[do not rebuild before running tests]' '--gdb[run tests under gdb]' '--gdb-path=[program to run for gdb (can be wrapper or compatible program)]:program:_path_commands' + '(--interactive -i)'{'--interactive','-i'}'[run tests with interactive input/output]' '--list[list available tests]' '(--wrapper --wrap)'{'--wrapper=','--wrap='}'[wrapper to run tests with]:wrapper program:_path_commands' "$__meson_cd" diff --git a/docs/markdown/Unit-tests.md b/docs/markdown/Unit-tests.md index dc509a8189b3..73e58dc68ab6 100644 --- a/docs/markdown/Unit-tests.md +++ b/docs/markdown/Unit-tests.md @@ -256,6 +256,16 @@ $ meson test --gdb --gdb-path /path/to/gdb testname $ meson test --print-errorlogs ``` +Running tests interactively can be done with the `--interactive` option. +`meson test --interactive` invokes tests with stdout, stdin and stderr +connected directly to the calling terminal. This can be useful if your test is +an integration test running in a container or virtual machine where a debug +shell is spawned if it fails *(added 1.5.0)*: + +```console +$ meson test --interactive testname +``` + Meson will report the output produced by the failing tests along with other useful information as the environmental variables. This is useful, for example, when you run the tests on Travis-CI, Jenkins and diff --git a/docs/markdown/snippets/test_interactive.md b/docs/markdown/snippets/test_interactive.md new file mode 100644 index 000000000000..907147fd9ca9 --- /dev/null +++ b/docs/markdown/snippets/test_interactive.md @@ -0,0 +1,6 @@ +## The Meson test program supports a new "--interactive" argument + +`meson test --interactive` invokes tests with stdout, stdin and stderr +connected directly to the calling terminal. This can be useful when running +integration tests that run in containers or virtual machines which can spawn a +debug shell if a test fails. diff --git a/mesonbuild/mtest.py b/mesonbuild/mtest.py index b9f72fac7dab..311274f37191 100644 --- a/mesonbuild/mtest.py +++ b/mesonbuild/mtest.py @@ -127,6 +127,8 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: help='Run test under gdb.') parser.add_argument('--gdb-path', default='gdb', dest='gdb_path', help='Path to the gdb binary (default: gdb).') + parser.add_argument('-i', '--interactive', default=False, dest='interactive', + action='store_true', help='Run tests with interactive input/output.') parser.add_argument('--list', default=False, dest='list', action='store_true', help='List available tests.') parser.add_argument('--wrapper', default=None, dest='wrapper', type=split_args, @@ -233,8 +235,8 @@ class ConsoleUser(enum.Enum): # the logger can use the console LOGGER = 0 - # the console is used by gdb - GDB = 1 + # the console is used by gdb or the user + INTERACTIVE = 1 # the console is used to write stdout/stderr STDOUT = 2 @@ -1417,7 +1419,7 @@ def __init__(self, test: TestSerialisation, env: T.Dict[str, str], name: str, if ('MSAN_OPTIONS' not in env or not env['MSAN_OPTIONS']): env['MSAN_OPTIONS'] = 'halt_on_error=1:abort_on_error=1:print_summary=1:print_stacktrace=1' - if self.options.gdb or self.test.timeout is None or self.test.timeout <= 0: + if self.options.interactive or self.test.timeout is None or self.test.timeout <= 0: timeout = None elif self.options.timeout_multiplier is None: timeout = self.test.timeout @@ -1426,12 +1428,12 @@ def __init__(self, test: TestSerialisation, env: T.Dict[str, str], name: str, else: timeout = self.test.timeout * self.options.timeout_multiplier - is_parallel = test.is_parallel and self.options.num_processes > 1 and not self.options.gdb + is_parallel = test.is_parallel and self.options.num_processes > 1 and not self.options.interactive verbose = (test.verbose or self.options.verbose) and not self.options.quiet self.runobj = TestRun(test, env, name, timeout, is_parallel, verbose) - if self.options.gdb: - self.console_mode = ConsoleUser.GDB + if self.options.interactive: + self.console_mode = ConsoleUser.INTERACTIVE elif self.runobj.direct_stdout: self.console_mode = ConsoleUser.STDOUT else: @@ -1499,13 +1501,13 @@ async def _run_subprocess(self, args: T.List[str], *, stdout: T.Optional[int], stderr: T.Optional[int], env: T.Dict[str, str], cwd: T.Optional[str]) -> TestSubprocess: # Let gdb handle ^C instead of us - if self.options.gdb: + if self.options.interactive: previous_sigint_handler = signal.getsignal(signal.SIGINT) # Make the meson executable ignore SIGINT while gdb is running. signal.signal(signal.SIGINT, signal.SIG_IGN) def preexec_fn() -> None: - if self.options.gdb: + if self.options.interactive: # Restore the SIGINT handler for the child process to # ensure it can handle it. signal.signal(signal.SIGINT, signal.SIG_DFL) @@ -1516,7 +1518,7 @@ def preexec_fn() -> None: os.setsid() def postwait_fn() -> None: - if self.options.gdb: + if self.options.interactive: # Let us accept ^C again signal.signal(signal.SIGINT, previous_sigint_handler) @@ -1530,7 +1532,7 @@ def postwait_fn() -> None: postwait_fn=postwait_fn if not is_windows() else None) async def _run_cmd(self, harness: 'TestHarness', cmd: T.List[str]) -> None: - if self.console_mode is ConsoleUser.GDB: + if self.console_mode is ConsoleUser.INTERACTIVE: stdout = None stderr = None else: @@ -1591,7 +1593,7 @@ def __init__(self, options: argparse.Namespace): self.ninja: T.List[str] = None self.logfile_base: T.Optional[str] = None - if self.options.logbase and not self.options.gdb: + if self.options.logbase and not self.options.interactive: namebase = None self.logfile_base = os.path.join(self.options.wd, 'meson-logs', self.options.logbase) @@ -1691,6 +1693,7 @@ def merge_setup_options(self, options: argparse.Namespace, test: TestSerialisati if not options.gdb: options.gdb = current.gdb if options.gdb: + options.interactive = True options.verbose = True if options.timeout_multiplier is None: options.timeout_multiplier = current.timeout_multiplier @@ -2143,7 +2146,7 @@ def convert_path_to_target(path: str) -> str: return True def run(options: argparse.Namespace) -> int: - if options.benchmark: + if options.benchmark or options.interactive: options.num_processes = 1 if options.verbose and options.quiet: @@ -2152,12 +2155,15 @@ def run(options: argparse.Namespace) -> int: check_bin = None if options.gdb: - options.verbose = True + options.interactive = True if options.wrapper: print('Must not specify both a wrapper and gdb at the same time.') return 1 check_bin = 'gdb' + if options.interactive: + options.verbose = True + if options.wrapper: check_bin = options.wrapper[0] From d68306c9c8641bfd200d77cd1afa0d032648c365 Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Sun, 21 Apr 2024 16:07:53 +0200 Subject: [PATCH 2/2] mtest: Connect /dev/null to stdin when not running in interactive mode This allows tests to check whether stdin is a tty to figure out if they're running in interactive mode or not. It also makes sure that tests that are not running in interactive mode don't inadvertendly try to read from stdin. --- mesonbuild/mtest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mesonbuild/mtest.py b/mesonbuild/mtest.py index 311274f37191..460a44c49da8 100644 --- a/mesonbuild/mtest.py +++ b/mesonbuild/mtest.py @@ -1497,7 +1497,7 @@ async def run(self, harness: 'TestHarness') -> TestRun: await self._run_cmd(harness, cmd) return self.runobj - async def _run_subprocess(self, args: T.List[str], *, + async def _run_subprocess(self, args: T.List[str], *, stdin: T.Optional[int], stdout: T.Optional[int], stderr: T.Optional[int], env: T.Dict[str, str], cwd: T.Optional[str]) -> TestSubprocess: # Let gdb handle ^C instead of us @@ -1523,6 +1523,7 @@ def postwait_fn() -> None: signal.signal(signal.SIGINT, previous_sigint_handler) p = await asyncio.create_subprocess_exec(*args, + stdin=stdin, stdout=stdout, stderr=stderr, env=env, @@ -1533,9 +1534,11 @@ def postwait_fn() -> None: async def _run_cmd(self, harness: 'TestHarness', cmd: T.List[str]) -> None: if self.console_mode is ConsoleUser.INTERACTIVE: + stdin = None stdout = None stderr = None else: + stdin = asyncio.subprocess.DEVNULL stdout = asyncio.subprocess.PIPE stderr = asyncio.subprocess.STDOUT \ if not self.options.split and not self.runobj.needs_parsing \ @@ -1549,6 +1552,7 @@ async def _run_cmd(self, harness: 'TestHarness', cmd: T.List[str]) -> None: extra_cmd.append(f'--gtest_output=xml:{gtestname}.xml') p = await self._run_subprocess(cmd + extra_cmd, + stdin=stdin, stdout=stdout, stderr=stderr, env=self.runobj.env,