diff --git a/.gitignore b/.gitignore index a067cc1b..d5fae1e5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ index.html *.exe # Autotools +/.utun-build-state* Makefile !tools/etcpmon/Makefile Makefile.in @@ -84,6 +85,7 @@ logs/ tests/test_* !tests/test_*.c !tests/test_*.h +!tests/test_*.py !tests/test_*.conf !tests/test_*.json tests/bench_* diff --git a/tests/test_build_script.py b/tests/test_build_script.py new file mode 100644 index 00000000..8cdb1939 --- /dev/null +++ b/tests/test_build_script.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Проверка настоящего build.sh на маленьком проекте Autotools, без изменения исходников uTun.""" +from pathlib import Path +import json +import os +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 = [] + +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): + 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(): + return run(['./src/utun']).stdout.strip() + +try: + shutil.copyfile(script, base / 'build.sh') + 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]) +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]) +if test "x$enable_hardening" = xyes; then + CFLAGS="$CFLAGS -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE" + LDFLAGS="$LDFLAGS -Wl,-z,relro -Wl,-z,now -pie" +fi +TEST_CONFIGURE_OPTION +AC_CONFIG_FILES([Makefile lib/Makefile lib/libopus/Makefile src/Makefile tests/Makefile]) +AC_OUTPUT +''') + 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 +#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') + write('tests/Makefile.am', 'noinst_PROGRAMS = probe\nprobe_SOURCES = probe.c\n') + write('tests/probe.c', 'int main(void) { return 0; }\n') + package_tool = write('bin/pkg-config', '''#!/bin/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()) + 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()) + + for mode in ['asan', 'hardened', 'normal']: + 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 '__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() + 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) + result = build('--clean') + check('explicit clean does not reconfigure', 'INFO: cleaning objects:' in result.stdout and 'INFO: configuring:' not in result.stdout) + 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', '#!/bin/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') + 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 diff --git a/tools/chatgui/build.sh b/tools/chatgui/build.sh index 5dffdb15..8b62d98e 100755 --- a/tools/chatgui/build.sh +++ b/tools/chatgui/build.sh @@ -14,6 +14,18 @@ log "" SRC_DIR="$(cd "$(dirname "$0")" && pwd)" BUILD_DIR="$SRC_DIR/build" RUNTIME_PREFIX="${MSYSTEM_PREFIX:-/ucrt64}" +JOBS=$(nproc 2>/dev/null || echo 4) +while [[ $# -gt 0 ]]; do + case "$1" in + -j) + [[ $# -gt 1 ]] || { log "ERROR: -j requires a positive job count"; exit 1; } + shift; JOBS=$1 ;; + -j*) JOBS=${1#-j} ;; + *) log "ERROR: unknown argument: $1"; exit 1 ;; + esac + shift +done +[[ "$JOBS" =~ ^[1-9][0-9]*$ ]] || { log "ERROR: invalid job count: $JOBS"; exit 1; } log "Source: $SRC_DIR" log "Build: $BUILD_DIR" @@ -36,7 +48,7 @@ if [ "${MSYSTEM:-}" = UCRT64 ]; then -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON 2>&1 | tee -a "$LOG_FILE" || { log " ERROR: bundled rlottie configure failed"; exit 1; } - cmake --build "$SRC_DIR/rlottie_build" --target rlottie --parallel 4 2>&1 | tee -a "$LOG_FILE" || { + cmake --build "$SRC_DIR/rlottie_build" --target rlottie --parallel "$JOBS" 2>&1 | tee -a "$LOG_FILE" || { log " ERROR: bundled rlottie build failed"; exit 1; } log " OK: bundled rlottie ready in $SRC_DIR/rlottie_build" @@ -47,7 +59,6 @@ cmake -S "$SRC_DIR" -B "$BUILD_DIR" -G Ninja 2>&1 | tee -a "$LOG_FILE" test ${PIPESTATUS[0]} -eq 0 || { log "CMake configure FAILED"; exit 1; } log "" -JOBS=$(nproc 2>/dev/null || echo 4) log "[2/4] Building (parallel: $JOBS jobs)..." cmake --build "$BUILD_DIR" -j"$JOBS" 2>&1 | tee -a "$LOG_FILE" test ${PIPESTATUS[0]} -eq 0 || { log "Build FAILED"; exit 1; }