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
34top_builddir = os.environ['top_builddir']
35top_srcdir = os.environ['top_srcdir']
36
37logging.basicConfig(level=logging.DEBUG)
38logger = logging.getLogger('test')
39logger.setLevel(logging.DEBUG)
40
41# Permutation of RMLVO that we use in multiple tests
42rmlvos = [list(x) for x in itertools.permutations(
43    ['--rules=evdev', '--model=pc104',
44     '--layout=ch', '--options=eurosign:5']
45)]
46
47
48def _disable_coredump():
49    resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
50
51
52def run_command(args):
53    logger.debug('run command: {}'.format(' '.join(args)))
54
55    try:
56        p = subprocess.run(args, preexec_fn=_disable_coredump,
57                           capture_output=True, text=True,
58                           timeout=0.7)
59        return p.returncode, p.stdout, p.stderr
60    except subprocess.TimeoutExpired as e:
61        return 0, e.stdout, e.stderr
62
63
64class XkbcliTool:
65    xkbcli_tool = 'xkbcli'
66    subtool = None
67
68    def __init__(self, subtool=None, skipIf=()):
69        self.tool_path = top_builddir
70        self.subtool = subtool
71        self.skipIf = skipIf
72
73    def run_command(self, args):
74        for condition, reason in self.skipIf:
75            if condition:
76                raise unittest.SkipTest(reason)
77        if self.subtool is not None:
78            tool = '{}-{}'.format(self.xkbcli_tool, self.subtool)
79        else:
80            tool = self.xkbcli_tool
81        args = [os.path.join(self.tool_path, tool)] + args
82
83        return run_command(args)
84
85    def run_command_success(self, args):
86        rc, stdout, stderr = self.run_command(args)
87        assert rc == 0, (stdout, stderr)
88        return stdout, stderr
89
90    def run_command_invalid(self, args):
91        rc, stdout, stderr = self.run_command(args)
92        assert rc == 2, (rc, stdout, stderr)
93        return rc, stdout, stderr
94
95    def run_command_unrecognized_option(self, args):
96        rc, stdout, stderr = self.run_command(args)
97        assert rc == 2, (rc, stdout, stderr)
98        assert stdout.startswith('Usage') or stdout == ''
99        assert 'unrecognized option' in stderr
100
101    def run_command_missing_arg(self, args):
102        rc, stdout, stderr = self.run_command(args)
103        assert rc == 2, (rc, stdout, stderr)
104        assert stdout.startswith('Usage') or stdout == ''
105        assert 'requires an argument' in stderr
106
107    def __str__(self):
108        return str(self.subtool)
109
110
111class TestXkbcli(unittest.TestCase):
112    @classmethod
113    def setUpClass(cls):
114        cls.xkbcli = XkbcliTool()
115        cls.xkbcli_list = XkbcliTool('list', skipIf=(
116            (not int(os.getenv('HAVE_XKBCLI_LIST', '1')), 'xkbregistory not enabled'),
117        ))
118        cls.xkbcli_how_to_type = XkbcliTool('how-to-type')
119        cls.xkbcli_compile_keymap = XkbcliTool('compile-keymap')
120        cls.xkbcli_interactive_evdev = XkbcliTool('interactive-evdev', skipIf=(
121            (not int(os.getenv('HAVE_XKBCLI_INTERACTIVE_EVDEV', '1')), 'evdev not enabled'),
122            (not os.path.exists('/dev/input/event0'), 'event node required'),
123            (not os.access('/dev/input/event0', os.R_OK), 'insufficient permissions'),
124        ))
125        cls.xkbcli_interactive_x11 = XkbcliTool('interactive-x11', skipIf=(
126            (not int(os.getenv('HAVE_XKBCLI_INTERACTIVE_X11', '1')), 'x11 not enabled'),
127            (not os.getenv('DISPLAY'), 'DISPLAY not set'),
128        ))
129        cls.xkbcli_interactive_wayland = XkbcliTool('interactive-wayland', skipIf=(
130            (not int(os.getenv('HAVE_XKBCLI_INTERACTIVE_WAYLAND', '1')), 'wayland not enabled'),
131            (not os.getenv('WAYLAND_DISPLAY'), 'WAYLAND_DISPLAY not set'),
132        ))
133        cls.all_tools = [
134            cls.xkbcli,
135            cls.xkbcli_list,
136            cls.xkbcli_how_to_type,
137            cls.xkbcli_compile_keymap,
138            cls.xkbcli_interactive_evdev,
139            cls.xkbcli_interactive_x11,
140            cls.xkbcli_interactive_wayland,
141        ]
142
143    def test_help(self):
144        # --help is supported by all tools
145        for tool in self.all_tools:
146            with self.subTest(tool=tool):
147                stdout, stderr = tool.run_command_success(['--help'])
148                assert stdout.startswith('Usage:')
149                assert stderr == ''
150
151    def test_invalid_option(self):
152        # --foobar generates "Usage:" for all tools
153        for tool in self.all_tools:
154            with self.subTest(tool=tool):
155                tool.run_command_unrecognized_option(['--foobar'])
156
157    def test_xkbcli_version(self):
158        # xkbcli --version
159        stdout, stderr = self.xkbcli.run_command_success(['--version'])
160        assert stdout.startswith('1')
161        assert stderr == ''
162
163    def test_xkbcli_too_many_args(self):
164        self.xkbcli.run_command_invalid(['a'] * 64)
165
166    def test_compile_keymap_args(self):
167        for args in (
168            ['--verbose'],
169            ['--rmlvo'],
170            # ['--kccgst'],
171            ['--verbose', '--rmlvo'],
172            # ['--verbose', '--kccgst'],
173        ):
174            with self.subTest(args=args):
175                self.xkbcli_compile_keymap.run_command_success(args)
176
177    def test_compile_keymap_rmlvo(self):
178        for rmlvo in rmlvos:
179            with self.subTest(rmlvo=rmlvo):
180                self.xkbcli_compile_keymap.run_command_success(rmlvo)
181
182    def test_compile_keymap_include(self):
183        for args in (
184            ['--include', '.', '--include-defaults'],
185            ['--include', '/tmp', '--include-defaults'],
186        ):
187            with self.subTest(args=args):
188                # Succeeds thanks to include-defaults
189                self.xkbcli_compile_keymap.run_command_success(args)
190
191    def test_compile_keymap_include_invalid(self):
192        # A non-directory is rejected by default
193        args = ['--include', '/proc/version']
194        rc, stdout, stderr = self.xkbcli_compile_keymap.run_command(args)
195        assert rc == 1, (stdout, stderr)
196        assert "There are no include paths to search" in stderr
197
198        # A non-existing directory is rejected by default
199        args = ['--include', '/tmp/does/not/exist']
200        rc, stdout, stderr = self.xkbcli_compile_keymap.run_command(args)
201        assert rc == 1, (stdout, stderr)
202        assert "There are no include paths to search" in stderr
203
204        # Valid dir, but missing files
205        args = ['--include', '/tmp']
206        rc, stdout, stderr = self.xkbcli_compile_keymap.run_command(args)
207        assert rc == 1, (stdout, stderr)
208        assert "Couldn't look up rules" in stderr
209
210    def test_how_to_type(self):
211        # Unicode codepoint conversions, we support whatever strtol does
212        for args in (['123'], ['0x123'], ['0123']):
213            with self.subTest(args=args):
214                self.xkbcli_how_to_type.run_command_success(args)
215
216    def test_how_to_type_rmlvo(self):
217        for rmlvo in rmlvos:
218            with self.subTest(rmlvo=rmlvo):
219                args = rmlvo + ['0x1234']
220                self.xkbcli_how_to_type.run_command_success(args)
221
222    def test_list_rmlvo(self):
223        for args in (
224            ['--verbose'],
225            ['-v'],
226            ['--verbose', '--load-exotic'],
227            ['--load-exotic'],
228            ['--ruleset=evdev'],
229            ['--ruleset=base'],
230        ):
231            with self.subTest(args=args):
232                self.xkbcli_list.run_command_success(args)
233
234    def test_list_rmlvo_includes(self):
235        args = ['/tmp/']
236        self.xkbcli_list.run_command_success(args)
237
238    def test_list_rmlvo_includes_invalid(self):
239        args = ['/proc/version']
240        rc, stdout, stderr = self.xkbcli_list.run_command(args)
241        assert rc == 1
242        assert "Failed to append include path" in stderr
243
244    def test_list_rmlvo_includes_no_defaults(self):
245        args = ['--skip-default-paths', '/tmp']
246        rc, stdout, stderr = self.xkbcli_list.run_command(args)
247        assert rc == 1
248        assert "Failed to parse XKB description" in stderr
249
250    def test_interactive_evdev_rmlvo(self):
251        for rmlvo in rmlvos:
252            with self.subTest(rmlvo=rmlvo):
253                self.xkbcli_interactive_evdev.run_command_success(rmlvos)
254
255    def test_interactive_evdev(self):
256        # Note: --enable-compose fails if $prefix doesn't have the compose tables
257        # installed
258        for args in (
259            ['--report-state-changes'],
260            ['--enable-compose'],
261            ['--consumed-mode=xkb'],
262            ['--consumed-mode=gtk'],
263            ['--without-x11-offset'],
264        ):
265            with self.subTest(args=args):
266                self.xkbcli_interactive_evdev.run_command_success(args)
267
268    def test_interactive_x11(self):
269        # To be filled in if we handle something other than --help
270        pass
271
272    def test_interactive_wayland(self):
273        # To be filled in if we handle something other than --help
274        pass
275
276
277if __name__ == '__main__':
278    with tempfile.TemporaryDirectory() as tmpdir:
279        # Use our own test xkeyboard-config copy.
280        os.environ['XKB_CONFIG_ROOT'] = top_srcdir + '/test/data'
281        # libxkbcommon has fallbacks when XDG_CONFIG_HOME isn't set so we need
282        # to override it with a known (empty) directory. Otherwise our test
283        # behavior depends on the system the test is run on.
284        os.environ['XDG_CONFIG_HOME'] = tmpdir
285        # Prevent the legacy $HOME/.xkb from kicking in.
286        del os.environ['HOME']
287        # This needs to be separated if we do specific extra path testing
288        os.environ['XKB_CONFIG_EXTRA_PATH'] = tmpdir
289
290        unittest.main()
291