1#!/usr/bin/env python3
2#
3# Copyright © 2020 Red Hat, Inc.
4#
5# Permission is hereby granted, free of charge, to any person obtaining a
6# copy of this software and associated documentation files (the "Software"),
7# to deal in the Software without restriction, including without limitation
8# the rights to use, copy, modify, merge, publish, distribute, sublicense,
9# and/or sell copies of the Software, and to permit persons to whom the
10# Software is furnished to do so, subject to the following conditions:
11#
12# The above copyright notice and this permission notice (including the next
13# paragraph) shall be included in all copies or substantial portions of the
14# Software.
15#
16# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
19# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
22# DEALINGS IN THE SOFTWARE.
23
24import itertools
25import os
26import resource
27import sys
28import subprocess
29import logging
30import tempfile
31import unittest
32
33
34try:
35    top_builddir = os.environ['top_builddir']
36    top_srcdir = os.environ['top_srcdir']
37except KeyError:
38    print('Required environment variables not found: top_srcdir/top_builddir', file=sys.stderr)
39    from pathlib import Path
40    top_srcdir = '.'
41    try:
42        top_builddir = next(Path('.').glob('**/meson-logs/')).parent
43    except StopIteration:
44        sys.exit(1)
45    print('Using srcdir "{}", builddir "{}"'.format(top_srcdir, top_builddir), file=sys.stderr)
46
47
48logging.basicConfig(level=logging.DEBUG)
49logger = logging.getLogger('test')
50logger.setLevel(logging.DEBUG)
51
52# Permutation of RMLVO that we use in multiple tests
53rmlvos = [list(x) for x in itertools.permutations(
54    ['--rules=evdev', '--model=pc104',
55     '--layout=ch', '--options=eurosign:5']
56)]
57
58
59def _disable_coredump():
60    resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
61
62
63def run_command(args):
64    logger.debug('run command: {}'.format(' '.join(args)))
65
66    try:
67        p = subprocess.run(args, preexec_fn=_disable_coredump,
68                           capture_output=True, text=True,
69                           timeout=0.7)
70        return p.returncode, p.stdout, p.stderr
71    except subprocess.TimeoutExpired as e:
72        return 0, e.stdout, e.stderr
73
74
75class XkbcliTool:
76    xkbcli_tool = 'xkbcli'
77    subtool = None
78
79    def __init__(self, subtool=None, skipIf=(), skipError=()):
80        self.tool_path = top_builddir
81        self.subtool = subtool
82        self.skipIf = skipIf
83        self.skipError = skipError
84
85    def run_command(self, args):
86        for condition, reason in self.skipIf:
87            if condition:
88                raise unittest.SkipTest(reason)
89        if self.subtool is not None:
90            tool = '{}-{}'.format(self.xkbcli_tool, self.subtool)
91        else:
92            tool = self.xkbcli_tool
93        args = [os.path.join(self.tool_path, tool)] + args
94
95        return run_command(args)
96
97    def run_command_success(self, args):
98        rc, stdout, stderr = self.run_command(args)
99        if rc != 0:
100            for testfunc, reason in self.skipError:
101                if testfunc(rc, stdout, stderr):
102                    raise unittest.SkipTest(reason)
103        assert rc == 0, (stdout, stderr)
104        return stdout, stderr
105
106    def run_command_invalid(self, args):
107        rc, stdout, stderr = self.run_command(args)
108        assert rc == 2, (rc, stdout, stderr)
109        return rc, stdout, stderr
110
111    def run_command_unrecognized_option(self, args):
112        rc, stdout, stderr = self.run_command(args)
113        assert rc == 2, (rc, stdout, stderr)
114        assert stdout.startswith('Usage') or stdout == ''
115        assert 'unrecognized option' in stderr
116
117    def run_command_missing_arg(self, args):
118        rc, stdout, stderr = self.run_command(args)
119        assert rc == 2, (rc, stdout, stderr)
120        assert stdout.startswith('Usage') or stdout == ''
121        assert 'requires an argument' in stderr
122
123    def __str__(self):
124        return str(self.subtool)
125
126
127class TestXkbcli(unittest.TestCase):
128    @classmethod
129    def setUpClass(cls):
130        cls.xkbcli = XkbcliTool()
131        cls.xkbcli_list = XkbcliTool('list', skipIf=(
132            (not int(os.getenv('HAVE_XKBCLI_LIST', '1')), 'xkbregistory not enabled'),
133        ))
134        cls.xkbcli_how_to_type = XkbcliTool('how-to-type')
135        cls.xkbcli_compile_keymap = XkbcliTool('compile-keymap')
136        cls.xkbcli_interactive_evdev = XkbcliTool('interactive-evdev', skipIf=(
137            (not int(os.getenv('HAVE_XKBCLI_INTERACTIVE_EVDEV', '1')), 'evdev not enabled'),
138            (not os.path.exists('/dev/input/event0'), 'event node required'),
139            (not os.access('/dev/input/event0', os.R_OK), 'insufficient permissions'),
140        ), skipError=(
141            (lambda rc, stdout, stderr: 'Couldn\'t find any keyboards' in stderr,
142                'No keyboards available'),
143        ),
144        )
145        cls.xkbcli_interactive_x11 = XkbcliTool('interactive-x11', skipIf=(
146            (not int(os.getenv('HAVE_XKBCLI_INTERACTIVE_X11', '1')), 'x11 not enabled'),
147            (not os.getenv('DISPLAY'), 'DISPLAY not set'),
148        ))
149        cls.xkbcli_interactive_wayland = XkbcliTool('interactive-wayland', skipIf=(
150            (not int(os.getenv('HAVE_XKBCLI_INTERACTIVE_WAYLAND', '1')), 'wayland not enabled'),
151            (not os.getenv('WAYLAND_DISPLAY'), 'WAYLAND_DISPLAY not set'),
152        ))
153        cls.all_tools = [
154            cls.xkbcli,
155            cls.xkbcli_list,
156            cls.xkbcli_how_to_type,
157            cls.xkbcli_compile_keymap,
158            cls.xkbcli_interactive_evdev,
159            cls.xkbcli_interactive_x11,
160            cls.xkbcli_interactive_wayland,
161        ]
162
163    def test_help(self):
164        # --help is supported by all tools
165        for tool in self.all_tools:
166            with self.subTest(tool=tool):
167                stdout, stderr = tool.run_command_success(['--help'])
168                assert stdout.startswith('Usage:')
169                assert stderr == ''
170
171    def test_invalid_option(self):
172        # --foobar generates "Usage:" for all tools
173        for tool in self.all_tools:
174            with self.subTest(tool=tool):
175                tool.run_command_unrecognized_option(['--foobar'])
176
177    def test_xkbcli_version(self):
178        # xkbcli --version
179        stdout, stderr = self.xkbcli.run_command_success(['--version'])
180        assert stdout.startswith('1')
181        assert stderr == ''
182
183    def test_xkbcli_too_many_args(self):
184        self.xkbcli.run_command_invalid(['a'] * 64)
185
186    def test_compile_keymap_args(self):
187        for args in (
188            ['--verbose'],
189            ['--rmlvo'],
190            # ['--kccgst'],
191            ['--verbose', '--rmlvo'],
192            # ['--verbose', '--kccgst'],
193        ):
194            with self.subTest(args=args):
195                self.xkbcli_compile_keymap.run_command_success(args)
196
197    def test_compile_keymap_rmlvo(self):
198        for rmlvo in rmlvos:
199            with self.subTest(rmlvo=rmlvo):
200                self.xkbcli_compile_keymap.run_command_success(rmlvo)
201
202    def test_compile_keymap_include(self):
203        for args in (
204            ['--include', '.', '--include-defaults'],
205            ['--include', '/tmp', '--include-defaults'],
206        ):
207            with self.subTest(args=args):
208                # Succeeds thanks to include-defaults
209                self.xkbcli_compile_keymap.run_command_success(args)
210
211    def test_compile_keymap_include_invalid(self):
212        # A non-directory is rejected by default
213        args = ['--include', '/proc/version']
214        rc, stdout, stderr = self.xkbcli_compile_keymap.run_command(args)
215        assert rc == 1, (stdout, stderr)
216        assert "There are no include paths to search" in stderr
217
218        # A non-existing directory is rejected by default
219        args = ['--include', '/tmp/does/not/exist']
220        rc, stdout, stderr = self.xkbcli_compile_keymap.run_command(args)
221        assert rc == 1, (stdout, stderr)
222        assert "There are no include paths to search" in stderr
223
224        # Valid dir, but missing files
225        args = ['--include', '/tmp']
226        rc, stdout, stderr = self.xkbcli_compile_keymap.run_command(args)
227        assert rc == 1, (stdout, stderr)
228        assert "Couldn't look up rules" in stderr
229
230    def test_how_to_type(self):
231        # Unicode codepoint conversions, we support whatever strtol does
232        for args in (['123'], ['0x123'], ['0123']):
233            with self.subTest(args=args):
234                self.xkbcli_how_to_type.run_command_success(args)
235
236    def test_how_to_type_rmlvo(self):
237        for rmlvo in rmlvos:
238            with self.subTest(rmlvo=rmlvo):
239                args = rmlvo + ['0x1234']
240                self.xkbcli_how_to_type.run_command_success(args)
241
242    def test_list_rmlvo(self):
243        for args in (
244            ['--verbose'],
245            ['-v'],
246            ['--verbose', '--load-exotic'],
247            ['--load-exotic'],
248            ['--ruleset=evdev'],
249            ['--ruleset=base'],
250        ):
251            with self.subTest(args=args):
252                self.xkbcli_list.run_command_success(args)
253
254    def test_list_rmlvo_includes(self):
255        args = ['/tmp/']
256        self.xkbcli_list.run_command_success(args)
257
258    def test_list_rmlvo_includes_invalid(self):
259        args = ['/proc/version']
260        rc, stdout, stderr = self.xkbcli_list.run_command(args)
261        assert rc == 1
262        assert "Failed to append include path" in stderr
263
264    def test_list_rmlvo_includes_no_defaults(self):
265        args = ['--skip-default-paths', '/tmp']
266        rc, stdout, stderr = self.xkbcli_list.run_command(args)
267        assert rc == 1
268        assert "Failed to parse XKB description" in stderr
269
270    def test_interactive_evdev_rmlvo(self):
271        for rmlvo in rmlvos:
272            with self.subTest(rmlvo=rmlvo):
273                self.xkbcli_interactive_evdev.run_command_success(rmlvo)
274
275    def test_interactive_evdev(self):
276        # Note: --enable-compose fails if $prefix doesn't have the compose tables
277        # installed
278        for args in (
279            ['--report-state-changes'],
280            ['--enable-compose'],
281            ['--consumed-mode=xkb'],
282            ['--consumed-mode=gtk'],
283            ['--without-x11-offset'],
284        ):
285            with self.subTest(args=args):
286                self.xkbcli_interactive_evdev.run_command_success(args)
287
288    def test_interactive_x11(self):
289        # To be filled in if we handle something other than --help
290        pass
291
292    def test_interactive_wayland(self):
293        # To be filled in if we handle something other than --help
294        pass
295
296
297if __name__ == '__main__':
298    with tempfile.TemporaryDirectory() as tmpdir:
299        # Use our own test xkeyboard-config copy.
300        os.environ['XKB_CONFIG_ROOT'] = top_srcdir + '/test/data'
301        # Use our own X11 locale copy.
302        os.environ['XLOCALEDIR'] = top_srcdir + '/test/data/locale'
303        # Use our own locale.
304        os.environ['LC_CTYPE'] = 'en_US.UTF-8'
305        # libxkbcommon has fallbacks when XDG_CONFIG_HOME isn't set so we need
306        # to override it with a known (empty) directory. Otherwise our test
307        # behavior depends on the system the test is run on.
308        os.environ['XDG_CONFIG_HOME'] = tmpdir
309        # Prevent the legacy $HOME/.xkb from kicking in.
310        del os.environ['HOME']
311        # This needs to be separated if we do specific extra path testing
312        os.environ['XKB_CONFIG_EXTRA_PATH'] = tmpdir
313
314        unittest.main()
315