1# Copyright 2016-2021 The Meson development team
2
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6
7#     http://www.apache.org/licenses/LICENSE-2.0
8
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from pathlib import PurePath
16from unittest import mock, TestCase, SkipTest
17import json
18import os
19import re
20import subprocess
21import sys
22import tempfile
23import typing as T
24
25import mesonbuild.mlog
26import mesonbuild.depfile
27import mesonbuild.dependencies.base
28import mesonbuild.dependencies.factory
29import mesonbuild.compilers
30import mesonbuild.envconfig
31import mesonbuild.environment
32import mesonbuild.coredata
33import mesonbuild.modules.gnome
34from mesonbuild.mesonlib import (
35    is_cygwin, windows_proof_rmtree, python_command
36)
37import mesonbuild.modules.pkgconfig
38
39
40from run_tests import (
41    Backend, ensure_backend_detects_changes, get_backend_commands,
42    get_builddir_target_args, get_meson_script, run_configure_inprocess,
43    run_mtest_inprocess
44)
45
46
47class BasePlatformTests(TestCase):
48    prefix = '/usr'
49    libdir = 'lib'
50
51    def setUp(self):
52        super().setUp()
53        self.maxDiff = None
54        src_root = str(PurePath(__file__).parents[1])
55        self.src_root = src_root
56        # Get the backend
57        self.backend = getattr(Backend, os.environ['MESON_UNIT_TEST_BACKEND'])
58        self.meson_args = ['--backend=' + self.backend.name]
59        self.meson_native_file = None
60        self.meson_cross_file = None
61        self.meson_command = python_command + [get_meson_script()]
62        self.setup_command = self.meson_command + self.meson_args
63        self.mconf_command = self.meson_command + ['configure']
64        self.mintro_command = self.meson_command + ['introspect']
65        self.wrap_command = self.meson_command + ['wrap']
66        self.rewrite_command = self.meson_command + ['rewrite']
67        # Backend-specific build commands
68        self.build_command, self.clean_command, self.test_command, self.install_command, \
69            self.uninstall_command = get_backend_commands(self.backend)
70        # Test directories
71        self.common_test_dir = os.path.join(src_root, 'test cases/common')
72        self.rust_test_dir = os.path.join(src_root, 'test cases/rust')
73        self.vala_test_dir = os.path.join(src_root, 'test cases/vala')
74        self.framework_test_dir = os.path.join(src_root, 'test cases/frameworks')
75        self.unit_test_dir = os.path.join(src_root, 'test cases/unit')
76        self.rewrite_test_dir = os.path.join(src_root, 'test cases/rewrite')
77        self.linuxlike_test_dir = os.path.join(src_root, 'test cases/linuxlike')
78        self.objc_test_dir = os.path.join(src_root, 'test cases/objc')
79        self.objcpp_test_dir = os.path.join(src_root, 'test cases/objcpp')
80
81        # Misc stuff
82        self.orig_env = os.environ.copy()
83        if self.backend is Backend.ninja:
84            self.no_rebuild_stdout = ['ninja: no work to do.', 'samu: nothing to do']
85        else:
86            # VS doesn't have a stable output when no changes are done
87            # XCode backend is untested with unit tests, help welcome!
88            self.no_rebuild_stdout = [f'UNKNOWN BACKEND {self.backend.name!r}']
89
90        self.builddirs = []
91        self.new_builddir()
92
93    def change_builddir(self, newdir):
94        self.builddir = newdir
95        self.privatedir = os.path.join(self.builddir, 'meson-private')
96        self.logdir = os.path.join(self.builddir, 'meson-logs')
97        self.installdir = os.path.join(self.builddir, 'install')
98        self.distdir = os.path.join(self.builddir, 'meson-dist')
99        self.mtest_command = self.meson_command + ['test', '-C', self.builddir]
100        self.builddirs.append(self.builddir)
101
102    def new_builddir(self):
103        # Keep builddirs inside the source tree so that virus scanners
104        # don't complain
105        newdir = tempfile.mkdtemp(dir=os.getcwd())
106        # In case the directory is inside a symlinked directory, find the real
107        # path otherwise we might not find the srcdir from inside the builddir.
108        newdir = os.path.realpath(newdir)
109        self.change_builddir(newdir)
110
111    def new_builddir_in_tempdir(self):
112        # Can't keep the builddir inside the source tree for the umask tests:
113        # https://github.com/mesonbuild/meson/pull/5546#issuecomment-509666523
114        # And we can't do this for all tests because it causes the path to be
115        # a short-path which breaks other tests:
116        # https://github.com/mesonbuild/meson/pull/9497
117        newdir = tempfile.mkdtemp()
118        # In case the directory is inside a symlinked directory, find the real
119        # path otherwise we might not find the srcdir from inside the builddir.
120        newdir = os.path.realpath(newdir)
121        self.change_builddir(newdir)
122
123    def _get_meson_log(self) -> T.Optional[str]:
124        log = os.path.join(self.logdir, 'meson-log.txt')
125        if not os.path.isfile(log):
126            print(f"{log!r} doesn't exist", file=sys.stderr)
127            return None
128        with open(log, encoding='utf-8') as f:
129            return f.read()
130
131    def _print_meson_log(self) -> None:
132        log = self._get_meson_log()
133        if log:
134            print(log)
135
136    def tearDown(self):
137        for path in self.builddirs:
138            try:
139                windows_proof_rmtree(path)
140            except FileNotFoundError:
141                pass
142        os.environ.clear()
143        os.environ.update(self.orig_env)
144        super().tearDown()
145
146    def _run(self, command, *, workdir=None, override_envvars: T.Optional[T.Mapping[str, str]] = None):
147        '''
148        Run a command while printing the stdout and stderr to stdout,
149        and also return a copy of it
150        '''
151        # If this call hangs CI will just abort. It is very hard to distinguish
152        # between CI issue and test bug in that case. Set timeout and fail loud
153        # instead.
154        if override_envvars is None:
155            env = None
156        else:
157            env = os.environ.copy()
158            env.update(override_envvars)
159
160        p = subprocess.run(command, stdout=subprocess.PIPE,
161                           stderr=subprocess.STDOUT, env=env,
162                           encoding='utf-8',
163                           universal_newlines=True, cwd=workdir, timeout=60 * 5)
164        print(p.stdout)
165        if p.returncode != 0:
166            if 'MESON_SKIP_TEST' in p.stdout:
167                raise SkipTest('Project requested skipping.')
168            raise subprocess.CalledProcessError(p.returncode, command, output=p.stdout)
169        return p.stdout
170
171    def init(self, srcdir, *,
172             extra_args=None,
173             default_args=True,
174             inprocess=False,
175             override_envvars: T.Optional[T.Mapping[str, str]] = None,
176             workdir=None,
177             allow_fail: bool = False) -> str:
178        """Call `meson setup`
179
180        :param allow_fail: If set to true initialization is allowed to fail.
181            When it does the log will be returned instead of stdout.
182        :return: the value of stdout on success, or the meson log on failure
183            when :param allow_fail: is true
184        """
185        self.assertPathExists(srcdir)
186        if extra_args is None:
187            extra_args = []
188        if not isinstance(extra_args, list):
189            extra_args = [extra_args]
190        args = [srcdir, self.builddir]
191        if default_args:
192            args += ['--prefix', self.prefix]
193            if self.libdir:
194                args += ['--libdir', self.libdir]
195            if self.meson_native_file:
196                args += ['--native-file', self.meson_native_file]
197            if self.meson_cross_file:
198                args += ['--cross-file', self.meson_cross_file]
199        self.privatedir = os.path.join(self.builddir, 'meson-private')
200        if inprocess:
201            try:
202                returncode, out, err = run_configure_inprocess(self.meson_args + args + extra_args, override_envvars)
203            except Exception as e:
204                if not allow_fail:
205                    self._print_meson_log()
206                    raise
207                out = self._get_meson_log()  # Best we can do here
208                err = ''  # type checkers can't figure out that on this path returncode will always be 0
209                returncode = 0
210            finally:
211                # Close log file to satisfy Windows file locking
212                mesonbuild.mlog.shutdown()
213                mesonbuild.mlog.log_dir = None
214                mesonbuild.mlog.log_file = None
215
216            if 'MESON_SKIP_TEST' in out:
217                raise SkipTest('Project requested skipping.')
218            if returncode != 0:
219                self._print_meson_log()
220                print('Stdout:\n')
221                print(out)
222                print('Stderr:\n')
223                print(err)
224                if not allow_fail:
225                    raise RuntimeError('Configure failed')
226        else:
227            try:
228                out = self._run(self.setup_command + args + extra_args, override_envvars=override_envvars, workdir=workdir)
229            except SkipTest:
230                raise SkipTest('Project requested skipping: ' + srcdir)
231            except Exception:
232                if not allow_fail:
233                    self._print_meson_log()
234                    raise
235                out = self._get_meson_log()  # best we can do here
236        return out
237
238    def build(self, target=None, *, extra_args=None, override_envvars=None):
239        if extra_args is None:
240            extra_args = []
241        # Add arguments for building the target (if specified),
242        # and using the build dir (if required, with VS)
243        args = get_builddir_target_args(self.backend, self.builddir, target)
244        return self._run(self.build_command + args + extra_args, workdir=self.builddir, override_envvars=override_envvars)
245
246    def clean(self, *, override_envvars=None):
247        dir_args = get_builddir_target_args(self.backend, self.builddir, None)
248        self._run(self.clean_command + dir_args, workdir=self.builddir, override_envvars=override_envvars)
249
250    def run_tests(self, *, inprocess=False, override_envvars=None):
251        if not inprocess:
252            self._run(self.test_command, workdir=self.builddir, override_envvars=override_envvars)
253        else:
254            with mock.patch.dict(os.environ, override_envvars):
255                run_mtest_inprocess(['-C', self.builddir])
256
257    def install(self, *, use_destdir=True, override_envvars=None):
258        if self.backend is not Backend.ninja:
259            raise SkipTest(f'{self.backend.name!r} backend can\'t install files')
260        if use_destdir:
261            destdir = {'DESTDIR': self.installdir}
262            if override_envvars is None:
263                override_envvars = destdir
264            else:
265                override_envvars.update(destdir)
266        self._run(self.install_command, workdir=self.builddir, override_envvars=override_envvars)
267
268    def uninstall(self, *, override_envvars=None):
269        self._run(self.uninstall_command, workdir=self.builddir, override_envvars=override_envvars)
270
271    def run_target(self, target, *, override_envvars=None):
272        '''
273        Run a Ninja target while printing the stdout and stderr to stdout,
274        and also return a copy of it
275        '''
276        return self.build(target=target, override_envvars=override_envvars)
277
278    def setconf(self, arg, will_build=True):
279        if not isinstance(arg, list):
280            arg = [arg]
281        if will_build:
282            ensure_backend_detects_changes(self.backend)
283        self._run(self.mconf_command + arg + [self.builddir])
284
285    def wipe(self):
286        windows_proof_rmtree(self.builddir)
287
288    def utime(self, f):
289        ensure_backend_detects_changes(self.backend)
290        os.utime(f)
291
292    def get_compdb(self):
293        if self.backend is not Backend.ninja:
294            raise SkipTest(f'Compiler db not available with {self.backend.name} backend')
295        try:
296            with open(os.path.join(self.builddir, 'compile_commands.json'), encoding='utf-8') as ifile:
297                contents = json.load(ifile)
298        except FileNotFoundError:
299            raise SkipTest('Compiler db not found')
300        # If Ninja is using .rsp files, generate them, read their contents, and
301        # replace it as the command for all compile commands in the parsed json.
302        if len(contents) > 0 and contents[0]['command'].endswith('.rsp'):
303            # Pretend to build so that the rsp files are generated
304            self.build(extra_args=['-d', 'keeprsp', '-n'])
305            for each in contents:
306                # Extract the actual command from the rsp file
307                compiler, rsp = each['command'].split(' @')
308                rsp = os.path.join(self.builddir, rsp)
309                # Replace the command with its contents
310                with open(rsp, encoding='utf-8') as f:
311                    each['command'] = compiler + ' ' + f.read()
312        return contents
313
314    def get_meson_log(self):
315        with open(os.path.join(self.builddir, 'meson-logs', 'meson-log.txt'), encoding='utf-8') as f:
316            return f.readlines()
317
318    def get_meson_log_compiler_checks(self):
319        '''
320        Fetch a list command-lines run by meson for compiler checks.
321        Each command-line is returned as a list of arguments.
322        '''
323        log = self.get_meson_log()
324        prefix = 'Command line:'
325        cmds = [l[len(prefix):].split() for l in log if l.startswith(prefix)]
326        return cmds
327
328    def get_meson_log_sanitychecks(self):
329        '''
330        Same as above, but for the sanity checks that were run
331        '''
332        log = self.get_meson_log()
333        prefix = 'Sanity check compiler command line:'
334        cmds = [l[len(prefix):].split() for l in log if l.startswith(prefix)]
335        return cmds
336
337    def introspect(self, args):
338        if isinstance(args, str):
339            args = [args]
340        out = subprocess.check_output(self.mintro_command + args + [self.builddir],
341                                      universal_newlines=True)
342        return json.loads(out)
343
344    def introspect_directory(self, directory, args):
345        if isinstance(args, str):
346            args = [args]
347        out = subprocess.check_output(self.mintro_command + args + [directory],
348                                      universal_newlines=True)
349        try:
350            obj = json.loads(out)
351        except Exception as e:
352            print(out)
353            raise e
354        return obj
355
356    def assertPathEqual(self, path1, path2):
357        '''
358        Handles a lot of platform-specific quirks related to paths such as
359        separator, case-sensitivity, etc.
360        '''
361        self.assertEqual(PurePath(path1), PurePath(path2))
362
363    def assertPathListEqual(self, pathlist1, pathlist2):
364        self.assertEqual(len(pathlist1), len(pathlist2))
365        worklist = list(zip(pathlist1, pathlist2))
366        for i in worklist:
367            if i[0] is None:
368                self.assertEqual(i[0], i[1])
369            else:
370                self.assertPathEqual(i[0], i[1])
371
372    def assertPathBasenameEqual(self, path, basename):
373        msg = f'{path!r} does not end with {basename!r}'
374        # We cannot use os.path.basename because it returns '' when the path
375        # ends with '/' for some silly reason. This is not how the UNIX utility
376        # `basename` works.
377        path_basename = PurePath(path).parts[-1]
378        self.assertEqual(PurePath(path_basename), PurePath(basename), msg)
379
380    def assertReconfiguredBuildIsNoop(self):
381        'Assert that we reconfigured and then there was nothing to do'
382        ret = self.build()
383        self.assertIn('The Meson build system', ret)
384        if self.backend is Backend.ninja:
385            for line in ret.split('\n'):
386                if line in self.no_rebuild_stdout:
387                    break
388            else:
389                raise AssertionError('build was reconfigured, but was not no-op')
390        elif self.backend is Backend.vs:
391            # Ensure that some target said that no rebuild was done
392            # XXX: Note CustomBuild did indeed rebuild, because of the regen checker!
393            self.assertIn('ClCompile:\n  All outputs are up-to-date.', ret)
394            self.assertIn('Link:\n  All outputs are up-to-date.', ret)
395            # Ensure that no targets were built
396            self.assertNotRegex(ret, re.compile('ClCompile:\n [^\n]*cl', flags=re.IGNORECASE))
397            self.assertNotRegex(ret, re.compile('Link:\n [^\n]*link', flags=re.IGNORECASE))
398        elif self.backend is Backend.xcode:
399            raise SkipTest('Please help us fix this test on the xcode backend')
400        else:
401            raise RuntimeError(f'Invalid backend: {self.backend.name!r}')
402
403    def assertBuildIsNoop(self):
404        ret = self.build()
405        if self.backend is Backend.ninja:
406            self.assertIn(ret.split('\n')[-2], self.no_rebuild_stdout)
407        elif self.backend is Backend.vs:
408            # Ensure that some target of each type said that no rebuild was done
409            # We always have at least one CustomBuild target for the regen checker
410            self.assertIn('CustomBuild:\n  All outputs are up-to-date.', ret)
411            self.assertIn('ClCompile:\n  All outputs are up-to-date.', ret)
412            self.assertIn('Link:\n  All outputs are up-to-date.', ret)
413            # Ensure that no targets were built
414            self.assertNotRegex(ret, re.compile('CustomBuild:\n [^\n]*cl', flags=re.IGNORECASE))
415            self.assertNotRegex(ret, re.compile('ClCompile:\n [^\n]*cl', flags=re.IGNORECASE))
416            self.assertNotRegex(ret, re.compile('Link:\n [^\n]*link', flags=re.IGNORECASE))
417        elif self.backend is Backend.xcode:
418            raise SkipTest('Please help us fix this test on the xcode backend')
419        else:
420            raise RuntimeError(f'Invalid backend: {self.backend.name!r}')
421
422    def assertRebuiltTarget(self, target):
423        ret = self.build()
424        if self.backend is Backend.ninja:
425            self.assertIn(f'Linking target {target}', ret)
426        elif self.backend is Backend.vs:
427            # Ensure that this target was rebuilt
428            linkre = re.compile('Link:\n [^\n]*link[^\n]*' + target, flags=re.IGNORECASE)
429            self.assertRegex(ret, linkre)
430        elif self.backend is Backend.xcode:
431            raise SkipTest('Please help us fix this test on the xcode backend')
432        else:
433            raise RuntimeError(f'Invalid backend: {self.backend.name!r}')
434
435    @staticmethod
436    def get_target_from_filename(filename):
437        base = os.path.splitext(filename)[0]
438        if base.startswith(('lib', 'cyg')):
439            return base[3:]
440        return base
441
442    def assertBuildRelinkedOnlyTarget(self, target):
443        ret = self.build()
444        if self.backend is Backend.ninja:
445            linked_targets = []
446            for line in ret.split('\n'):
447                if 'Linking target' in line:
448                    fname = line.rsplit('target ')[-1]
449                    linked_targets.append(self.get_target_from_filename(fname))
450            self.assertEqual(linked_targets, [target])
451        elif self.backend is Backend.vs:
452            # Ensure that this target was rebuilt
453            linkre = re.compile(r'Link:\n  [^\n]*link.exe[^\n]*/OUT:".\\([^"]*)"', flags=re.IGNORECASE)
454            matches = linkre.findall(ret)
455            self.assertEqual(len(matches), 1, msg=matches)
456            self.assertEqual(self.get_target_from_filename(matches[0]), target)
457        elif self.backend is Backend.xcode:
458            raise SkipTest('Please help us fix this test on the xcode backend')
459        else:
460            raise RuntimeError(f'Invalid backend: {self.backend.name!r}')
461
462    def assertPathExists(self, path):
463        m = f'Path {path!r} should exist'
464        self.assertTrue(os.path.exists(path), msg=m)
465
466    def assertPathDoesNotExist(self, path):
467        m = f'Path {path!r} should not exist'
468        self.assertFalse(os.path.exists(path), msg=m)
469