254 lines
14 KiB
Python
254 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Проверка настоящего build.sh на маленьком проекте Autotools, без изменения исходников uTun."""
|
|
from pathlib import Path
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
script = Path(sys.argv[1]).resolve()
|
|
base = Path(tempfile.mkdtemp(prefix='utun-build-tests-'))
|
|
log = base / 'commands.log'
|
|
environment = {**os.environ, 'ASAN_OPTIONS': 'detect_leaks=0'}
|
|
environment.pop('MAKEFLAGS', None)
|
|
environment.pop('MSYSTEM', None)
|
|
for name in ['CC', 'CFLAGS', 'CPPFLAGS', 'LDFLAGS', 'LIBS', 'DEBUG_FLAGS']:
|
|
environment.pop(name, None)
|
|
checks = []
|
|
default_compiler = 'clang' if sys.platform.startswith('freebsd') else 'gcc'
|
|
make_tool = environment.get('MAKE') or ('gmake' if sys.platform.startswith('freebsd') else 'make')
|
|
|
|
def write(name, content):
|
|
path = base / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content)
|
|
return path
|
|
|
|
def run(args, ok=True, extra_environment=None):
|
|
if args[0] == 'make':
|
|
args = [make_tool, *args[1:]]
|
|
result = subprocess.run(args, cwd=base, env={**environment, **(extra_environment or {})}, text=True, capture_output=True)
|
|
with log.open('a') as output:
|
|
output.write(f'{args!r}\nexit={result.returncode}\n{result.stdout}{result.stderr}\n')
|
|
if ok and result.returncode:
|
|
raise AssertionError(f'{args!r}: exit={result.returncode}; see {log}\n{result.stderr[-1500:]}')
|
|
return result
|
|
|
|
def build(*args, **kwargs):
|
|
return run(['bash', 'build.sh', '--no-gui', '-j', '2', *args], **kwargs)
|
|
|
|
def check(name, condition):
|
|
if not condition:
|
|
raise AssertionError(f'{name}; see {log}')
|
|
checks.append(name)
|
|
print(f'[PASS] build.sh: {name}', flush=True)
|
|
|
|
def times():
|
|
return {str(p.relative_to(base)): p.stat().st_mtime_ns for p in base.rglob('*.o')}
|
|
|
|
def value():
|
|
binary = base / 'src/utun'
|
|
if not binary.exists():
|
|
binary = binary.with_suffix('.exe')
|
|
return run([str(binary)]).stdout.strip()
|
|
|
|
try:
|
|
shutil.copyfile(script, base / 'build.sh')
|
|
actual_configuration = (script.parent / 'configure.ac').read_text()
|
|
hardening_start = actual_configuration.index('if test "x$enable_hardening" = "xyes"; then')
|
|
hardening_end = actual_configuration.index('# ==================== НАДЁЖНЫЙ ДЕТЕКТ WINDOWS', hardening_start)
|
|
hardening_rules = actual_configuration[hardening_start:hardening_end]
|
|
write('configure.ac', '''AC_INIT([build-test], [1.0])
|
|
AC_CONFIG_SRCDIR([src/main.c])
|
|
AC_CONFIG_HEADERS([config.h])
|
|
AC_CONFIG_MACRO_DIR([m4])
|
|
AC_CANONICAL_HOST
|
|
AM_INIT_AUTOMAKE([foreign subdir-objects])
|
|
AC_PROG_CC
|
|
AC_PROG_RANLIB
|
|
AM_PROG_AR
|
|
AC_ARG_WITH([ffmpeg], [], [], [with_ffmpeg=no])
|
|
AC_ARG_ENABLE([hardening], [], [], [enable_hardening=no])
|
|
__HARDENING_RULES__
|
|
TEST_CONFIGURE_OPTION
|
|
AC_CONFIG_FILES([Makefile lib/Makefile lib/libopus/Makefile src/Makefile tests/Makefile])
|
|
AC_OUTPUT
|
|
'''.replace('__HARDENING_RULES__', hardening_rules))
|
|
write('m4/test.m4', 'AC_DEFUN([TEST_CONFIGURE_OPTION], [AC_DEFINE([TEST_OPTION], [1], [Test option])])\n')
|
|
write('Makefile.am', 'SUBDIRS = lib lib/libopus src tests\n')
|
|
write('lib/Makefile.am', 'noinst_LIBRARIES = libfixture.a\nlibfixture_a_SOURCES = fixture.c\n')
|
|
write('lib/fixture.c', 'int fixture(void) { return 0; }\n')
|
|
write('lib/libopus/Makefile.am', '''noinst_LIBRARIES = libopus_internal.a
|
|
libopus_internal_a_SOURCES = opus.c
|
|
CFLAGS = -g -Og -fno-omit-frame-pointer -fno-pie -fPIC
|
|
''')
|
|
write('lib/libopus/opus.c', 'int opus_fixture(void) { return 0; }\n')
|
|
write('src/Makefile.am', '''bin_PROGRAMS = utun
|
|
utun_SOURCES = main.c worker.c api.h nested.h
|
|
AM_CFLAGS = -DLOCAL_OPTION=1
|
|
utun_LDADD = ../lib/libfixture.a ../lib/libopus/libopus_internal.a
|
|
''')
|
|
write('src/main.c', '''#include <stdio.h>
|
|
#include "api.h"
|
|
int worker(void);
|
|
int fixture(void);
|
|
int opus_fixture(void);
|
|
int main(void) { printf("%d\\n", worker() + fixture() + opus_fixture()); return 0; }
|
|
''')
|
|
write('src/worker.c', '#include "api.h"\nint worker(void) { return VALUE + LOCAL_OPTION; }\n')
|
|
write('src/api.h', '#include "nested.h"\n')
|
|
write('src/nested.h', '#define VALUE 10\n')
|
|
test_rules = (script.parent / 'tests/Makefile.am').read_text()
|
|
config_rules = test_rules[test_rules.index('all-local: copy-test-configs'):test_rules.index('# Test logs directory')]
|
|
write('tests/Makefile.am', 'noinst_PROGRAMS = probe\nprobe_SOURCES = probe.c\n' + config_rules)
|
|
write('tests/probe.c', 'int main(void) { return 0; }\n')
|
|
package_tool = write('bin/pkg-config', '''#!/usr/bin/env bash
|
|
[[ "${BUILD_TEST_FFMPEG:-0}" = 1 ]] || exit 1
|
|
case "$1" in
|
|
--exists) exit 0 ;;
|
|
--modversion) echo 1.0 ;;
|
|
--cflags|--libs-only-L) exit 0 ;;
|
|
*) exit 1 ;;
|
|
esac
|
|
''')
|
|
package_tool.chmod(0o755)
|
|
environment['PATH'] = str(base / 'bin') + os.pathsep + environment['PATH']
|
|
|
|
result = build()
|
|
check('fresh build generates, configures and builds dependencies', value() == '11' and (base / 'lib/libopus/libopus_internal.a').exists())
|
|
check('normal build includes tests', (base / 'tests/probe').exists() or (base / 'tests/probe.exe').exists())
|
|
initial = times()
|
|
result = build()
|
|
check('repeat build does not regenerate, configure or compile', initial == times() and 'INFO: configuring:' not in result.stdout and 'INFO: regenerating' not in result.stdout)
|
|
time.sleep(1.1)
|
|
write('src/nested.h', '#define VALUE 20\n')
|
|
result = build()
|
|
current = times()
|
|
check('transitive header rebuilds both dependent objects', current['src/main.o'] > initial['src/main.o'] and current['src/worker.o'] > initial['src/worker.o'])
|
|
check('header change preserves unrelated objects and configuration', current['lib/fixture.o'] == initial['lib/fixture.o'] and 'INFO: configuring:' not in result.stdout and value() == '21')
|
|
initial = times()
|
|
write('src/unused.h', '#define UNUSED 1\n')
|
|
build()
|
|
check('unrelated header does not recompile', initial == times())
|
|
|
|
write('asan_probe.c', 'int main(void) { return 0; }\n')
|
|
asan_supported = run([default_compiler, '-fsanitize=address', '-fno-pie', '-no-pie', 'asan_probe.c', '-o', 'asan_probe'], ok=False).returncode == 0
|
|
modes = ['asan', 'hardened', 'normal'] if asan_supported else ['hardened', 'normal']
|
|
if not asan_supported:
|
|
print('[SKIP] build.sh: ASAN runtime is unavailable in this compiler toolchain', flush=True)
|
|
for mode in modes:
|
|
args = [] if mode == 'normal' else ['--' + mode]
|
|
result = build(*args)
|
|
symbols = run(['nm', '-u', 'src/worker.o']).stdout
|
|
check(f'{mode}: changing mode reconfigures and cleans', 'INFO: configuring:' in result.stdout and 'INFO: cleaning objects:' in result.stdout)
|
|
check(f'{mode}: object instrumentation matches mode', ('__asan_' in symbols) == (mode == 'asan'))
|
|
if mode == 'asan':
|
|
check('ASAN skips tests and preserves unsanitized Opus', not (base / 'tests/probe').exists() and not (base / 'tests/probe.exe').exists() and '__asan_' not in run(['nm', '-u', 'lib/libopus/opus.o']).stdout)
|
|
initial = times()
|
|
result = build(*args)
|
|
check(f'{mode}: repeat build is incremental', initial == times() and 'INFO: configuring:' not in result.stdout and 'INFO: regenerating' not in result.stdout)
|
|
|
|
result = build('CFLAGS=-g -O0 -DUSER_OPTION=1')
|
|
check('quoted compiler flags are passed intact through configure', 'CFLAGS = -g -O0 -DUSER_OPTION=1' in (base / 'src/Makefile').read_text())
|
|
initial = times()
|
|
build('CFLAGS=-g -O0 -DUSER_OPTION=1')
|
|
check('repeat build with quoted flags is incremental', initial == times())
|
|
build()
|
|
compiler = shutil.which(default_compiler)
|
|
for directory in ['compiler-first', 'compiler-second']:
|
|
wrapper = write(directory + '/' + default_compiler, '#!/usr/bin/env bash\nexec ' + shlex.quote(compiler) + ' "$@"\n')
|
|
wrapper.chmod(0o755)
|
|
build(extra_environment={'PATH': str(base / 'compiler-first') + os.pathsep + environment['PATH']})
|
|
result = build(extra_environment={'PATH': str(base / 'compiler-second') + os.pathsep + environment['PATH']})
|
|
check('compiler path change is detected even with identical version', 'INFO: configuring:' in result.stdout)
|
|
build()
|
|
time.sleep(1.1)
|
|
write('src/Makefile.am', (base / 'src/Makefile.am').read_text().replace('LOCAL_OPTION=1', 'LOCAL_OPTION=2'))
|
|
result = build()
|
|
check('Makefile.am flag change rebuilds stale objects', 'INFO: regenerating' in result.stdout and value() == '22')
|
|
macro = base / 'm4/test.m4'
|
|
saved_time = macro.stat()
|
|
write('m4/test.m4', macro.read_text().replace('[1]', '[2]'))
|
|
os.utime(macro, ns=(saved_time.st_atime_ns, saved_time.st_mtime_ns))
|
|
build()
|
|
check('macro content change is detected with unchanged timestamp', '#define TEST_OPTION 2' in (base / 'config.h').read_text())
|
|
result = build(extra_environment={'BUILD_TEST_FFMPEG': '1'})
|
|
check('newly available optional dependency reconfigures', 'INFO: configuring:' in result.stdout and '--with-ffmpeg' in run(['./config.status', '--config']).stdout)
|
|
result = build()
|
|
check('disappearing optional dependency reconfigures', 'INFO: configuring:' in result.stdout and '--without-ffmpeg' in run(['./config.status', '--config']).stdout)
|
|
(base / 'src/.deps/worker.Po').unlink()
|
|
result = build()
|
|
check('missing dependencies force rebuild without configure', 'missing_dependencies=1' in result.stdout and 'INFO: configuring:' not in result.stdout)
|
|
write('src/.deps/worker.Po', '# dummy\n')
|
|
result = build()
|
|
check('dummy dependencies force rebuild', 'missing_dependencies=1' in result.stdout)
|
|
write('tests/test_preserve.conf', 'preserve configuration\n')
|
|
write('tests/test_preserve.json', '{"preserve":true}\n')
|
|
result = build('--clean')
|
|
check('explicit clean does not reconfigure', 'INFO: cleaning objects:' in result.stdout and 'INFO: configuring:' not in result.stdout)
|
|
check('in-source clean preserves configuration files', (base / 'tests/test_preserve.conf').exists() and (base / 'tests/test_preserve.json').exists())
|
|
result = build('--full')
|
|
check('explicit full regenerates, configures and rebuilds', 'INFO: regenerating' in result.stdout and 'INFO: configuring:' in result.stdout)
|
|
(base / 'src/Makefile').unlink()
|
|
result = build()
|
|
check('missing child Makefile is recreated', (base / 'src/Makefile').exists() and 'INFO: configuring:' in result.stdout)
|
|
run(['./configure', 'CFLAGS=-O0'])
|
|
result = build()
|
|
check('external configure is detected', 'INFO: configuring:' in result.stdout and 'CFLAGS = -g -O2' in (base / 'src/Makefile').read_text())
|
|
check('conflicting modes fail', build('--asan', '--hardened', ok=False).returncode != 0)
|
|
check('invalid job count fails', build('-j0', ok=False).returncode != 0)
|
|
|
|
state = (base / '.utun-build-state').read_bytes()
|
|
original = (base / 'src/worker.c').read_text()
|
|
write('src/worker.c', original + '\n#error deliberate_failure\n')
|
|
check('compiler failure propagates without committing state', build(ok=False).returncode != 0 and (base / '.utun-build-state').read_bytes() == state)
|
|
write('src/worker.c', original)
|
|
check('build recovers from compiler failure', build().returncode == 0 and value() == '22')
|
|
original = (base / 'configure.ac').read_text()
|
|
write('configure.ac', original + '\nm4_fatal([deliberate_autoconf_failure])\n')
|
|
check('autoreconf failure propagates through tee', build(ok=False).returncode != 0 and (base / '.utun-build-state').read_bytes() == state)
|
|
write('configure.ac', original)
|
|
build()
|
|
write('tools/chatgui/build/CMakeCache.txt', 'placeholder\n')
|
|
cmake = write('bin/cmake', '#!/usr/bin/env bash\nprintf "%s\\n" "$*" > gui-arguments\nexit 9\n')
|
|
cmake.chmod(0o755)
|
|
result = run(['bash', 'build.sh', '-j', '3'], ok=False)
|
|
check('GUI failure propagates and success is not printed', result.returncode != 0 and 'Build completed successfully!' not in result.stdout)
|
|
check('GUI receives selected job count', (base / 'gui-arguments').read_text().strip() == '--build tools/chatgui/build --parallel 3')
|
|
(base / 'tools/chatgui/build/CMakeCache.txt').unlink()
|
|
write('tools/chatgui/package_win.sh', '''#!/usr/bin/env bash
|
|
printf '%s\\n' "$*" > installer-preflight
|
|
[[ "${MSYSTEM:-}" = UCRT64 ]] || { echo 'ERROR: installer requires UCRT64'; exit 1; }
|
|
''')
|
|
write('tools/chatgui/build.sh', '#!/usr/bin/env bash\nprintf "%s\\n" "$*" > gui-installer-arguments\nexit 9\n')
|
|
result = run(['bash', 'build.sh', '--installer', '-j5'], ok=False, extra_environment={'MSYSTEM': 'UCRT64'})
|
|
check('installer builds GUI without an existing CMake cache',
|
|
(base / 'tools/chatgui/gui-installer-arguments').read_text().strip() == '-j5 --installer')
|
|
check('installer preflight runs before building', (base / 'installer-preflight').read_text().strip() == '--check')
|
|
check('installer failure propagates through the root build', result.returncode != 0 and 'Build completed successfully!' not in result.stdout)
|
|
result = run(['bash', 'build.sh', '--installer'], ok=False)
|
|
check('installer requires a Windows build environment', result.returncode != 0 and 'ERROR: installer requires UCRT64' in result.stdout)
|
|
result = run(['bash', 'build.sh', '--installer', '--no-gui'], ok=False)
|
|
check('installer rejects a disabled GUI', result.returncode != 0 and '--installer requires GUI' in result.stderr)
|
|
run(['make', 'distclean'])
|
|
output_dir = base / 'out'
|
|
output_dir.mkdir()
|
|
for args in [['../configure'], [make_tool, '-j2'], [make_tool, '-C', 'tests', 'clean-local']]:
|
|
result = subprocess.run(args, cwd=output_dir, env=environment, capture_output=True, text=True)
|
|
with log.open('a') as output:
|
|
output.write(f'OUT-OF-TREE {args!r}\nexit={result.returncode}\n{result.stdout}{result.stderr}\n')
|
|
if result.returncode:
|
|
raise AssertionError(f'out-of-tree {args!r}: see {log}')
|
|
if args[0] == make_tool and '-j2' in args:
|
|
check('out-of-tree build copies test configurations', (output_dir / 'tests/test_preserve.conf').exists())
|
|
check('out-of-tree clean removes copies and preserves source configurations',
|
|
not (output_dir / 'tests/test_preserve.conf').exists() and (base / 'tests/test_preserve.conf').exists())
|
|
print(f'[PASS] build.sh: {len(checks)} checks completed', flush=True)
|
|
shutil.rmtree(base)
|
|
except Exception:
|
|
print(f'[FAIL] build.sh: diagnostic files retained in {base}', file=sys.stderr)
|
|
raise
|