The Meson Build System
http://mesonbuild.com/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
512 lines
22 KiB
512 lines
22 KiB
# 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 |
|
|
|
@mock.patch.dict(os.environ) |
|
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}'] |
|
os.environ['COLUMNS'] = '80' |
|
os.environ['PYTHONIOENCODING'] = 'utf8' |
|
|
|
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]) |
|
|
|
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
|
|
|