| # SPDX-License-Identifier: Apache-2.0 | |
| # Copyright 2016-2021 The Meson development team | |
| # Copyright © 2024 Intel Corporation | |
| from __future__ import annotations | |
| from pathlib import PurePath | |
| from unittest import mock, TestCase, SkipTest | |
| import json | |
| import io | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import shutil | |
| 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, join_args, split_args, windows_proof_rmtree, python_command | |
| ) | |
| import mesonbuild.modules.pkgconfig | |
| from run_tests import ( | |
| Backend, get_backend_commands, | |
| get_builddir_target_args, get_meson_script, run_configure_inprocess, | |
| run_mtest_inprocess, handle_meson_skip_test, | |
| ) | |
| # magic attribute used by unittest.result.TestResult._is_relevant_tb_level | |
| # This causes tracebacks to hide these internal implementation details, | |
| # e.g. for assertXXX helpers. | |
| __unittest = True | |
| class BasePlatformTests(TestCase): | |
| prefix = '/usr' | |
| libdir = 'lib' | |
| @classmethod | |
| def setUpClass(cls) -> None: | |
| super().setUpClass() | |
| cls.maxDiff = None | |
| src_root = str(PurePath(__file__).parents[1]) | |
| cls.src_root = src_root | |
| # Get the backend | |
| cls.backend_name = os.environ.get('MESON_UNIT_TEST_BACKEND', 'ninja') | |
| backend_type = 'vs' if cls.backend_name.startswith('vs') else cls.backend_name | |
| cls.backend = getattr(Backend, backend_type) | |
| cls.meson_args = ['--backend=' + cls.backend_name] | |
| cls.meson_command = python_command + [get_meson_script()] | |
| cls.setup_command = cls.meson_command + ['setup'] + cls.meson_args | |
| cls.mconf_command = cls.meson_command + ['configure'] | |
| cls.mintro_command = cls.meson_command + ['introspect'] | |
| cls.wrap_command = cls.meson_command + ['wrap'] | |
| cls.rewrite_command = cls.meson_command + ['rewrite'] | |
| # Backend-specific build commands | |
| cls.build_command, cls.clean_command, cls.test_command, cls.install_command, \ | |
| cls.uninstall_command = get_backend_commands(cls.backend) | |
| # Test directories | |
| cls.common_test_dir = os.path.join(src_root, 'test cases/common') | |
| cls.python_test_dir = os.path.join(src_root, 'test cases/python') | |
| cls.rust_test_dir = os.path.join(src_root, 'test cases/rust') | |
| cls.vala_test_dir = os.path.join(src_root, 'test cases/vala') | |
| cls.framework_test_dir = os.path.join(src_root, 'test cases/frameworks') | |
| cls.unit_test_dir = os.path.join(src_root, 'test cases/unit') | |
| cls.rewrite_test_dir = os.path.join(src_root, 'test cases/rewrite') | |
| cls.linuxlike_test_dir = os.path.join(src_root, 'test cases/linuxlike') | |
| cls.objc_test_dir = os.path.join(src_root, 'test cases/objc') | |
| cls.objcpp_test_dir = os.path.join(src_root, 'test cases/objcpp') | |
| cls.darwin_test_dir = os.path.join(src_root, 'test cases/darwin') | |
| # Misc stuff | |
| if cls.backend is Backend.ninja: | |
| cls.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! | |
| cls.no_rebuild_stdout = [f'UNKNOWN BACKEND {cls.backend.name!r}'] | |
| cls.env_patch = mock.patch.dict(os.environ) | |
| cls.env_patch.start() | |
| os.environ['COLUMNS'] = '80' | |
| os.environ['PYTHONIOENCODING'] = 'utf8' | |
| @classmethod | |
| def tearDownClass(cls) -> None: | |
| super().tearDownClass() | |
| cls.env_patch.stop() | |
| def setUp(self): | |
| super().setUp() | |
| self.meson_native_files = [] | |
| self.meson_cross_files = [] | |
| 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] | |
| if os.path.islink(newdir): | |
| self.addCleanup(os.unlink, self.builddir) | |
| else: | |
| self.addCleanup(windows_proof_rmtree, 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 _open_meson_log(self) -> io.TextIOWrapper: | |
| log = os.path.join(self.logdir, 'meson-log.txt') | |
| return open(log, encoding='utf-8') | |
| def _get_meson_log(self) -> T.Optional[str]: | |
| try: | |
| with self._open_meson_log() as f: | |
| return f.read() | |
| except FileNotFoundError as e: | |
| print(f"{e.filename!r} doesn't exist", file=sys.stderr) | |
| return None | |
| def _print_meson_log(self) -> None: | |
| log = self._get_meson_log() | |
| if log: | |
| print(log) | |
| def _run(self, command, *, workdir=None, override_envvars: T.Optional[T.Mapping[str, str]] = None, stderr=True): | |
| ''' | |
| 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) | |
| proc = subprocess.run(command, stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT if stderr else subprocess.PIPE, | |
| env=env, | |
| encoding='utf-8', | |
| text=True, cwd=workdir, timeout=60 * 5) | |
| print('$', join_args(command)) | |
| print('stdout:') | |
| print(proc.stdout) | |
| if not stderr: | |
| print('stderr:') | |
| print(proc.stderr) | |
| if proc.returncode != 0: | |
| skipped, reason = handle_meson_skip_test(proc.stdout) | |
| if skipped: | |
| raise SkipTest(f'Project requested skipping: {reason}') | |
| raise subprocess.CalledProcessError(proc.returncode, command, output=proc.stdout) | |
| return proc.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] | |
| build_and_src_dir_args = [self.builddir, srcdir] | |
| args = [] | |
| if default_args: | |
| args += ['--prefix', self.prefix] | |
| if self.libdir: | |
| args += ['--libdir', self.libdir] | |
| for f in self.meson_native_files: | |
| args += ['--native-file', f] | |
| for f in self.meson_cross_files: | |
| args += ['--cross-file', f] | |
| self.privatedir = os.path.join(self.builddir, 'meson-private') | |
| if inprocess: | |
| try: | |
| returncode, out, err = run_configure_inprocess(['setup'] + self.meson_args + args + extra_args + build_and_src_dir_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._logger.log_dir = None | |
| mesonbuild.mlog._logger.log_file = None | |
| skipped, reason = handle_meson_skip_test(out) | |
| if skipped: | |
| raise SkipTest(f'Project requested skipping: {reason}') | |
| 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 + build_and_src_dir_args, override_envvars=override_envvars, workdir=workdir) | |
| 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, stderr=True): | |
| 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, stderr=stderr) | |
| 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: | |
| return self._run(self.test_command, workdir=self.builddir, override_envvars=override_envvars) | |
| else: | |
| with mock.patch.dict(os.environ, override_envvars): | |
| return run_mtest_inprocess(['-C', self.builddir])[1] | |
| 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) | |
| return 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: T.Sequence[str], will_build: bool = True) -> None: | |
| if isinstance(arg, str): | |
| arg = [arg] | |
| else: | |
| arg = list(arg) | |
| self._run(self.mconf_command + arg + [self.builddir]) | |
| if will_build: | |
| self.build() | |
| def getconf(self, optname: str): | |
| opts = self.introspect('--buildoptions') | |
| for x in opts: | |
| if x.get('name') == optname: | |
| return x.get('value') | |
| self.fail(f'Option {optname} not found') | |
| def wipe(self): | |
| windows_proof_rmtree(self.builddir) | |
| def utime(self, f): | |
| 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_raw(self): | |
| with self._open_meson_log() as f: | |
| return f.read() | |
| def get_meson_log(self): | |
| with self._open_meson_log() 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. | |
| ''' | |
| prefix = 'Command line: `' | |
| suffix = '` -> 0\n' | |
| with self._open_meson_log() as log: | |
| cmds = [split_args(l[len(prefix):-len(suffix)]) 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 | |
| ''' | |
| prefix = 'Sanity check compiler command line:' | |
| with self._open_meson_log() as log: | |
| 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], | |
| encoding='utf-8', 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], | |
| encoding='utf-8', 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(stderr=False) | |
| 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(stderr=False) | |
| 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) | |
| def assertLength(self, val, length): | |
| assert len(val) == length, f'{val} is not length {length}' | |
| def copy_srcdir(self, srcdir: str) -> str: | |
| """Copies a source tree and returns that copy. | |
| ensures that the copied tree is deleted after running. | |
| :param srcdir: The location of the source tree to copy | |
| :return: The location of the copy | |
| """ | |
| dest = tempfile.mkdtemp() | |
| self.addCleanup(windows_proof_rmtree, dest) | |
| # shutil.copytree expects the destination directory to not exist, Once | |
| # python 3.8 is required the `dirs_exist_ok` parameter negates the need | |
| # for this | |
| dest = os.path.join(dest, 'subdir') | |
| shutil.copytree(srcdir, dest) | |
| return dest |