# Copyright 2016-2021 The Meson development team # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # http://www.apache.org/licenses/LICENSE-2.0 # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from pathlib import PurePath from unittest import mock, TestCase, SkipTest import json import os import re import subprocess import sys import tempfile import typing as T import mesonbuild.mlog import mesonbuild.depfile import mesonbuild.dependencies.base import mesonbuild.dependencies.factory import mesonbuild.compilers import mesonbuild.envconfig import mesonbuild.environment import mesonbuild.coredata import mesonbuild.modules.gnome from mesonbuild.mesonlib import ( is_cygwin, windows_proof_rmtree, python_command ) import mesonbuild.modules.pkgconfig from run_tests import ( Backend, ensure_backend_detects_changes, get_backend_commands, get_builddir_target_args, get_meson_script, run_configure_inprocess, run_mtest_inprocess ) class BasePlatformTests(TestCase): prefix = '/usr' libdir = 'lib' def setUp(self): super().setUp() self.maxDiff = None src_root = str(PurePath(__file__).parents[1]) self.src_root = src_root # Get the backend self.backend = getattr(Backend, os.environ['MESON_UNIT_TEST_BACKEND']) self.meson_args = ['--backend=' + self.backend.name] self.meson_native_file = None self.meson_cross_file = None self.meson_command = python_command + [get_meson_script()] self.setup_command = self.meson_command + self.meson_args self.mconf_command = self.meson_command + ['configure'] self.mintro_command = self.meson_command + ['introspect'] self.wrap_command = self.meson_command + ['wrap'] self.rewrite_command = self.meson_command + ['rewrite'] # Backend-specific build commands self.build_command, self.clean_command, self.test_command, self.install_command, \ self.uninstall_command = get_backend_commands(self.backend) # Test directories self.common_test_dir = os.path.join(src_root, 'test cases/common') self.rust_test_dir = os.path.join(src_root, 'test cases/rust') self.vala_test_dir = os.path.join(src_root, 'test cases/vala') self.framework_test_dir = os.path.join(src_root, 'test cases/frameworks') self.unit_test_dir = os.path.join(src_root, 'test cases/unit') self.rewrite_test_dir = os.path.join(src_root, 'test cases/rewrite') self.linuxlike_test_dir = os.path.join(src_root, 'test cases/linuxlike') self.objc_test_dir = os.path.join(src_root, 'test cases/objc') self.objcpp_test_dir = os.path.join(src_root, 'test cases/objcpp') # Misc stuff self.orig_env = os.environ.copy() if self.backend is Backend.ninja: self.no_rebuild_stdout = ['ninja: no work to do.', 'samu: nothing to do'] else: # VS doesn't have a stable output when no changes are done # XCode backend is untested with unit tests, help welcome! self.no_rebuild_stdout = [f'UNKNOWN BACKEND {self.backend.name!r}'] self.builddirs = [] self.new_builddir() def change_builddir(self, newdir): self.builddir = newdir self.privatedir = os.path.join(self.builddir, 'meson-private') self.logdir = os.path.join(self.builddir, 'meson-logs') self.installdir = os.path.join(self.builddir, 'install') self.distdir = os.path.join(self.builddir, 'meson-dist') self.mtest_command = self.meson_command + ['test', '-C', self.builddir] self.builddirs.append(self.builddir) def new_builddir(self): # Keep builddirs inside the source tree so that virus scanners # don't complain newdir = tempfile.mkdtemp(dir=os.getcwd()) # In case the directory is inside a symlinked directory, find the real # path otherwise we might not find the srcdir from inside the builddir. newdir = os.path.realpath(newdir) self.change_builddir(newdir) def new_builddir_in_tempdir(self): # Can't keep the builddir inside the source tree for the umask tests: # https://github.com/mesonbuild/meson/pull/5546#issuecomment-509666523 # And we can't do this for all tests because it causes the path to be # a short-path which breaks other tests: # https://github.com/mesonbuild/meson/pull/9497 newdir = tempfile.mkdtemp() # In case the directory is inside a symlinked directory, find the real # path otherwise we might not find the srcdir from inside the builddir. newdir = os.path.realpath(newdir) self.change_builddir(newdir) def _get_meson_log(self) -> T.Optional[str]: log = os.path.join(self.logdir, 'meson-log.txt') if not os.path.isfile(log): print(f"{log!r} doesn't exist", file=sys.stderr) return None with open(log, encoding='utf-8') as f: return f.read() def _print_meson_log(self) -> None: log = self._get_meson_log() if log: print(log) def tearDown(self): for path in self.builddirs: try: windows_proof_rmtree(path) except FileNotFoundError: pass os.environ.clear() os.environ.update(self.orig_env) super().tearDown() def _run(self, command, *, workdir=None, override_envvars: T.Optional[T.Mapping[str, str]] = None): ''' Run a command while printing the stdout and stderr to stdout, and also return a copy of it ''' # If this call hangs CI will just abort. It is very hard to distinguish # between CI issue and test bug in that case. Set timeout and fail loud # instead. if override_envvars is None: env = None else: env = os.environ.copy() env.update(override_envvars) p = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, encoding='utf-8', text=True, cwd=workdir, timeout=60 * 5) print(p.stdout) if p.returncode != 0: if 'MESON_SKIP_TEST' in p.stdout: raise SkipTest('Project requested skipping.') raise subprocess.CalledProcessError(p.returncode, command, output=p.stdout) return p.stdout def init(self, srcdir, *, extra_args=None, default_args=True, inprocess=False, override_envvars: T.Optional[T.Mapping[str, str]] = None, workdir=None, allow_fail: bool = False) -> str: """Call `meson setup` :param allow_fail: If set to true initialization is allowed to fail. When it does the log will be returned instead of stdout. :return: the value of stdout on success, or the meson log on failure when :param allow_fail: is true """ self.assertPathExists(srcdir) if extra_args is None: extra_args = [] if not isinstance(extra_args, list): extra_args = [extra_args] args = [srcdir, self.builddir] if default_args: args += ['--prefix', self.prefix] if self.libdir: args += ['--libdir', self.libdir] if self.meson_native_file: args += ['--native-file', self.meson_native_file] if self.meson_cross_file: args += ['--cross-file', self.meson_cross_file] self.privatedir = os.path.join(self.builddir, 'meson-private') if inprocess: try: returncode, out, err = run_configure_inprocess(self.meson_args + args + extra_args, override_envvars) except Exception as e: if not allow_fail: self._print_meson_log() raise out = self._get_meson_log() # Best we can do here err = '' # type checkers can't figure out that on this path returncode will always be 0 returncode = 0 finally: # Close log file to satisfy Windows file locking mesonbuild.mlog.shutdown() mesonbuild.mlog.log_dir = None mesonbuild.mlog.log_file = None if 'MESON_SKIP_TEST' in out: raise SkipTest('Project requested skipping.') if returncode != 0: self._print_meson_log() print('Stdout:\n') print(out) print('Stderr:\n') print(err) if not allow_fail: raise RuntimeError('Configure failed') else: try: out = self._run(self.setup_command + args + extra_args, override_envvars=override_envvars, workdir=workdir) except SkipTest: raise SkipTest('Project requested skipping: ' + srcdir) except Exception: if not allow_fail: self._print_meson_log() raise out = self._get_meson_log() # best we can do here return out def build(self, target=None, *, extra_args=None, override_envvars=None): if extra_args is None: extra_args = [] # Add arguments for building the target (if specified), # and using the build dir (if required, with VS) args = get_builddir_target_args(self.backend, self.builddir, target) return self._run(self.build_command + args + extra_args, workdir=self.builddir, override_envvars=override_envvars) def clean(self, *, override_envvars=None): dir_args = get_builddir_target_args(self.backend, self.builddir, None) self._run(self.clean_command + dir_args, workdir=self.builddir, override_envvars=override_envvars) def run_tests(self, *, inprocess=False, override_envvars=None): if not inprocess: self._run(self.test_command, workdir=self.builddir, override_envvars=override_envvars) else: with mock.patch.dict(os.environ, override_envvars): run_mtest_inprocess(['-C', self.builddir]) def install(self, *, use_destdir=True, override_envvars=None): if self.backend is not Backend.ninja: raise SkipTest(f'{self.backend.name!r} backend can\'t install files') if use_destdir: destdir = {'DESTDIR': self.installdir} if override_envvars is None: override_envvars = destdir else: override_envvars.update(destdir) self._run(self.install_command, workdir=self.builddir, override_envvars=override_envvars) def uninstall(self, *, override_envvars=None): self._run(self.uninstall_command, workdir=self.builddir, override_envvars=override_envvars) def run_target(self, target, *, override_envvars=None): ''' Run a Ninja target while printing the stdout and stderr to stdout, and also return a copy of it ''' return self.build(target=target, override_envvars=override_envvars) def setconf(self, arg, will_build=True): if not isinstance(arg, list): arg = [arg] if will_build: ensure_backend_detects_changes(self.backend) self._run(self.mconf_command + arg + [self.builddir]) def wipe(self): windows_proof_rmtree(self.builddir) def utime(self, f): ensure_backend_detects_changes(self.backend) os.utime(f) def get_compdb(self): if self.backend is not Backend.ninja: raise SkipTest(f'Compiler db not available with {self.backend.name} backend') try: with open(os.path.join(self.builddir, 'compile_commands.json'), encoding='utf-8') as ifile: contents = json.load(ifile) except FileNotFoundError: raise SkipTest('Compiler db not found') # If Ninja is using .rsp files, generate them, read their contents, and # replace it as the command for all compile commands in the parsed json. if len(contents) > 0 and contents[0]['command'].endswith('.rsp'): # Pretend to build so that the rsp files are generated self.build(extra_args=['-d', 'keeprsp', '-n']) for each in contents: # Extract the actual command from the rsp file compiler, rsp = each['command'].split(' @') rsp = os.path.join(self.builddir, rsp) # Replace the command with its contents with open(rsp, encoding='utf-8') as f: each['command'] = compiler + ' ' + f.read() return contents def get_meson_log(self): with open(os.path.join(self.builddir, 'meson-logs', 'meson-log.txt'), encoding='utf-8') as f: return f.readlines() def get_meson_log_compiler_checks(self): ''' Fetch a list command-lines run by meson for compiler checks. Each command-line is returned as a list of arguments. ''' log = self.get_meson_log() prefix = 'Command line:' cmds = [l[len(prefix):].split() for l in log if l.startswith(prefix)] return cmds def get_meson_log_sanitychecks(self): ''' Same as above, but for the sanity checks that were run ''' log = self.get_meson_log() prefix = 'Sanity check compiler command line:' cmds = [l[len(prefix):].split() for l in log if l.startswith(prefix)] return cmds def introspect(self, args): if isinstance(args, str): args = [args] out = subprocess.check_output(self.mintro_command + args + [self.builddir], universal_newlines=True) return json.loads(out) def introspect_directory(self, directory, args): if isinstance(args, str): args = [args] out = subprocess.check_output(self.mintro_command + args + [directory], universal_newlines=True) try: obj = json.loads(out) except Exception as e: print(out) raise e return obj def assertPathEqual(self, path1, path2): ''' Handles a lot of platform-specific quirks related to paths such as separator, case-sensitivity, etc. ''' self.assertEqual(PurePath(path1), PurePath(path2)) def assertPathListEqual(self, pathlist1, pathlist2): self.assertEqual(len(pathlist1), len(pathlist2)) worklist = list(zip(pathlist1, pathlist2)) for i in worklist: if i[0] is None: self.assertEqual(i[0], i[1]) else: self.assertPathEqual(i[0], i[1]) def assertPathBasenameEqual(self, path, basename): msg = f'{path!r} does not end with {basename!r}' # We cannot use os.path.basename because it returns '' when the path # ends with '/' for some silly reason. This is not how the UNIX utility # `basename` works. path_basename = PurePath(path).parts[-1] self.assertEqual(PurePath(path_basename), PurePath(basename), msg) def assertReconfiguredBuildIsNoop(self): 'Assert that we reconfigured and then there was nothing to do' ret = self.build() self.assertIn('The Meson build system', ret) if self.backend is Backend.ninja: for line in ret.split('\n'): if line in self.no_rebuild_stdout: break else: raise AssertionError('build was reconfigured, but was not no-op') elif self.backend is Backend.vs: # Ensure that some target said that no rebuild was done # XXX: Note CustomBuild did indeed rebuild, because of the regen checker! self.assertIn('ClCompile:\n All outputs are up-to-date.', ret) self.assertIn('Link:\n All outputs are up-to-date.', ret) # Ensure that no targets were built self.assertNotRegex(ret, re.compile('ClCompile:\n [^\n]*cl', flags=re.IGNORECASE)) self.assertNotRegex(ret, re.compile('Link:\n [^\n]*link', flags=re.IGNORECASE)) elif self.backend is Backend.xcode: raise SkipTest('Please help us fix this test on the xcode backend') else: raise RuntimeError(f'Invalid backend: {self.backend.name!r}') def assertBuildIsNoop(self): ret = self.build() if self.backend is Backend.ninja: self.assertIn(ret.split('\n')[-2], self.no_rebuild_stdout) elif self.backend is Backend.vs: # Ensure that some target of each type said that no rebuild was done # We always have at least one CustomBuild target for the regen checker self.assertIn('CustomBuild:\n All outputs are up-to-date.', ret) self.assertIn('ClCompile:\n All outputs are up-to-date.', ret) self.assertIn('Link:\n All outputs are up-to-date.', ret) # Ensure that no targets were built self.assertNotRegex(ret, re.compile('CustomBuild:\n [^\n]*cl', flags=re.IGNORECASE)) self.assertNotRegex(ret, re.compile('ClCompile:\n [^\n]*cl', flags=re.IGNORECASE)) self.assertNotRegex(ret, re.compile('Link:\n [^\n]*link', flags=re.IGNORECASE)) elif self.backend is Backend.xcode: raise SkipTest('Please help us fix this test on the xcode backend') else: raise RuntimeError(f'Invalid backend: {self.backend.name!r}') def assertRebuiltTarget(self, target): ret = self.build() if self.backend is Backend.ninja: self.assertIn(f'Linking target {target}', ret) elif self.backend is Backend.vs: # Ensure that this target was rebuilt linkre = re.compile('Link:\n [^\n]*link[^\n]*' + target, flags=re.IGNORECASE) self.assertRegex(ret, linkre) elif self.backend is Backend.xcode: raise SkipTest('Please help us fix this test on the xcode backend') else: raise RuntimeError(f'Invalid backend: {self.backend.name!r}') @staticmethod def get_target_from_filename(filename): base = os.path.splitext(filename)[0] if base.startswith(('lib', 'cyg')): return base[3:] return base def assertBuildRelinkedOnlyTarget(self, target): ret = self.build() if self.backend is Backend.ninja: linked_targets = [] for line in ret.split('\n'): if 'Linking target' in line: fname = line.rsplit('target ')[-1] linked_targets.append(self.get_target_from_filename(fname)) self.assertEqual(linked_targets, [target]) elif self.backend is Backend.vs: # Ensure that this target was rebuilt linkre = re.compile(r'Link:\n [^\n]*link.exe[^\n]*/OUT:".\\([^"]*)"', flags=re.IGNORECASE) matches = linkre.findall(ret) self.assertEqual(len(matches), 1, msg=matches) self.assertEqual(self.get_target_from_filename(matches[0]), target) elif self.backend is Backend.xcode: raise SkipTest('Please help us fix this test on the xcode backend') else: raise RuntimeError(f'Invalid backend: {self.backend.name!r}') def assertPathExists(self, path): m = f'Path {path!r} should exist' self.assertTrue(os.path.exists(path), msg=m) def assertPathDoesNotExist(self, path): m = f'Path {path!r} should not exist' self.assertFalse(os.path.exists(path), msg=m)