From 804007c997cb7cf9f9fb3682add7f48d80d9d07d Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 11 Oct 2026 00:25:43 +0200 Subject: [PATCH] Test Windows installer contents and build error propagation --- tests/test_build_script.py | 15 ++ tools/chatgui/tests/test_windows_installer.py | 206 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 tools/chatgui/tests/test_windows_installer.py diff --git a/tests/test_build_script.py b/tests/test_build_script.py index dd42407d..8cae7d98 100644 --- a/tests/test_build_script.py +++ b/tests/test_build_script.py @@ -218,6 +218,21 @@ esac 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() diff --git a/tools/chatgui/tests/test_windows_installer.py b/tools/chatgui/tests/test_windows_installer.py new file mode 100644 index 00000000..48066221 --- /dev/null +++ b/tools/chatgui/tests/test_windows_installer.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Проверка реальной упаковки с подставленными PE-инспектором, pacman и Inno-компилятором.""" +from pathlib import Path +import json +import os +import shutil +import subprocess +import tempfile + +source = Path(__file__).resolve().parents[1] +root = Path(tempfile.mkdtemp(prefix='vibechat-installer-tests-')) +case = root / 'repository with spaces' +gui = case / 'tools/chatgui' +build = gui / 'build' +commands = root / 'commands' +prefix = root / 'ucrt64' +environment = {**os.environ, 'MSYSTEM': 'UCRT64', 'MSYSTEM_PREFIX': str(prefix), 'WINDIR': str(root / 'Windows')} + + +def write(path, text, executable=False): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + if executable: + path.chmod(0o755) + return path + + +def run(*arguments, ok=True, extra=None): + result = subprocess.run(['bash', str(gui / 'package_win.sh'), *arguments], + env={**environment, **(extra or {})}, text=True, capture_output=True) + with (root / 'checks.log').open('a') as log: + log.write(f'{arguments!r}: exit={result.returncode}\n{result.stdout}{result.stderr}\n') + if ok: + assert result.returncode == 0, result.stdout + result.stderr + else: + assert result.returncode != 0, 'Unexpected packaging success' + assert 'ERROR:' in result.stdout + result.stderr + assert not list(build.glob('installer-stage.*')), 'Staging directory leaked' + return result + + +def package(): + run() + artifact = build / 'installer/VibeChat-1.2.3-win64-setup.exe' + return json.loads(artifact.read_text()) + + +try: + for name in ('package_win.sh', 'compiler_runtime.sh', 'build.sh', 'installer/vibechat.iss'): + target = gui / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / name, target) + for name in ('LICENSE', 'LICENSING', 'THIRD_PARTY_NOTICES'): + write(case / name, 'fixture license\n') + write(gui / 'rlottie_src/COPYING', 'rlottie notice\n') + write(gui / 'rlottie_src/licenses/COPYING.MIT', 'MIT notice\n') + write(prefix / 'share/licenses/fixture/COPYING', 'runtime notice\n') + write(root / 'Windows/System32/KERNEL32.dll', 'system file\n') + write(commands / 'cygpath', '#!/bin/bash\nprintf "%s\\n" "$2"\n', executable=True) + write(commands / 'objdump', '''#!/bin/bash +set -eu +if [[ "$1" = -f ]]; then + if [[ $(head -1 "$2") = wrong-architecture ]]; then echo 'file format pei-i386'; else echo 'file format pei-x86-64'; fi +else + cat "$2" +fi +''', executable=True) + write(commands / 'pacman', '''#!/bin/bash +set -eu +if [[ "$1" = -Qqo ]]; then echo fixture-runtime; else echo "$MSYSTEM_PREFIX/share/licenses/fixture/COPYING"; fi +''', executable=True) + compiler = write(commands / 'Inno Setup/ISCC.exe', '''#!/usr/bin/env python3 +from pathlib import Path +import json, os, sys +assert os.environ['MSYS2_ARG_CONV_EXCL'] == '*' +args = sys.argv[1:] +package = Path(next(a.removeprefix('/DPackageDir=') for a in args if a.startswith('/DPackageDir='))) +version = next(a.removeprefix('/DAppVersion=') for a in args if a.startswith('/DAppVersion=')) +output = Path(next(a[2:] for a in args if a.startswith('/O'))) +assert Path(args[-1]).name == 'vibechat.iss' +if os.environ.get('COMPILER_FAIL'): sys.exit(2) +if os.environ.get('COMPILER_NO_OUTPUT'): sys.exit(0) +files = sorted(str(p.relative_to(package)) for p in package.rglob('*') if p.is_file()) +(output / f'VibeChat-{version}-win64-setup.exe').write_text(json.dumps({'files': files, 'arguments': args})) +''', executable=True) + environment.update(ISCC=str(compiler), PATH=str(commands) + os.pathsep + os.environ['PATH']) + write(build / 'chatgui_version.txt', '1.2.3\n') + write(build / 'chatgui_qt_package.txt', 'Qt6\n') + write(build / 'vibechat.exe', 'DLL Name: Qt6Widgets.dll\nDLL Name: Qt6Network.dll\nDLL Name: librlottie.dll\n') + libraries = { + 'Qt6Widgets.dll': ['Qt6Core.dll', 'Qt6Gui.dll'], + 'Qt6Core.dll': ['KERNEL32.dll', 'api-ms-win-core-test.dll'], + 'Qt6Gui.dll': ['helper.dll'], 'Qt6Network.dll': ['Qt6Core.dll'], + 'helper.dll': ['Qt6Core.dll'], 'librlottie.dll': ['KERNEL32.dll'], + 'platforms/qwindows.dll': ['platform-helper.dll'], + 'imageformats/qjpeg.dll': ['jpeg-helper.dll'], 'imageformats/qwebp.dll': ['webp-helper.dll'], + 'platform-helper.dll': ['KERNEL32.dll'], 'jpeg-helper.dll': ['KERNEL32.dll'], 'webp-helper.dll': ['KERNEL32.dll'], + } + for name, imports in libraries.items(): + content = ''.join(f'DLL Name: {dll}\n' for dll in imports) + write(build / name, content) + if '/' in name: + runtime = prefix / 'share/qt6/plugins' / name + else: + runtime = prefix / 'bin' / name + write(runtime, content) + for name in ('vibechat.cfg', 'chatgui.cfg', 'chat_data/chats.db', 'chat_data/media/private.bin', + 'chatgui.log', 'vibechat.lock', 'test_gui.exe', 'CMakeCache.txt', 'Qt5Core.dll', 'obsolete.dll'): + write(build / name, 'PRIVATE OR STALE FILE\n') + + run('--check') + assert not (build / 'installer').exists(), 'Preflight created build artifacts' + result = package() + expected = {'vibechat.exe', *libraries, 'licenses/LICENSE', 'licenses/LICENSING', 'licenses/THIRD_PARTY_NOTICES', + 'licenses/rlottie/COPYING', 'licenses/rlottie/licenses/COPYING.MIT', 'licenses/runtime/fixture/COPYING'} + assert set(result['files']) == expected, result['files'] + print('PASS: complete dependency graph, Qt plugin helpers and license notices; private files and stale DLLs excluded') + + result2 = package() + assert result2['files'] == result['files'] + print('PASS: repeated packaging and paths containing spaces') + + missing = build / 'jpeg-helper.dll' + saved = missing.read_text() + missing.unlink() + failed = run(ok=False) + assert 'qjpeg.dll requires jpeg-helper.dll' in failed.stderr + write(missing, saved) + print('PASS: missing transitive plugin dependency rejects incomplete installer') + + write(build / 'helper.dll', 'wrong-architecture\n') + failed = run(ok=False) + assert 'not a Windows x64 binary' in failed.stderr + write(build / 'helper.dll', 'DLL Name: Qt6Core.dll\n') + print('PASS: wrong binary architecture is rejected') + + write(build / 'Qt6Core.dll', 'DLL Name: vcruntime140.dll\n') + write(root / 'Windows/System32/vcruntime140.dll', 'developer-only redistributable\n') + failed = run(ok=False) + assert 'requires vcruntime140.dll' in failed.stderr + write(build / 'Qt6Core.dll', 'DLL Name: KERNEL32.dll\n') + print('PASS: developer-installed MSVC runtime does not masquerade as an OS dependency') + + write(build / 'helper.dll', 'DLL Name: Qt5Core.dll\n') + failed = run(ok=False) + assert 'mixed Qt runtime' in failed.stderr + write(build / 'helper.dll', 'DLL Name: Qt6Core.dll\n') + print('PASS: an imported DLL from a different Qt version is rejected') + + write(build / 'chatgui_version.txt', 'invalid\n') + run(ok=False) + write(build / 'chatgui_version.txt', '1.2.3\n') + previous = (build / 'installer/VibeChat-1.2.3-win64-setup.exe').read_bytes() + run(ok=False, extra={'COMPILER_FAIL': '1'}) + run(ok=False, extra={'COMPILER_NO_OUTPUT': '1'}) + assert (build / 'installer/VibeChat-1.2.3-win64-setup.exe').read_bytes() == previous + print('PASS: invalid version and compiler failures cannot reuse stale output as success') + + run('--check', ok=False, extra={'ISCC': str(root / 'missing.exe')}) + run('--check', ok=False, extra={'MSYSTEM': ''}) + print('PASS: missing Inno compiler and non-Windows shell fail preflight') + + # Проверяем уведомления кастомного FFmpeg вместе со статически встроенными кодеками. + original_exe = (build / 'vibechat.exe').read_text() + write(build / 'vibechat.exe', original_exe + 'DLL Name: avcodec-61.dll\n') + write(build / 'avcodec-61.dll', 'DLL Name: KERNEL32.dll\n') + write(gui / 'ffmpeg/ffmpeg_build/install/bin/avcodec-61.dll', 'DLL Name: KERNEL32.dll\n') + write(gui / 'ffmpeg/ffmpeg_build/src/ffmpeg/COPYING.GPLv3', 'FFmpeg license\n') + write(gui / 'ffmpeg/ffmpeg_build/src/x264/COPYING', 'x264 license\n') + ffmpeg = package() + assert 'avcodec-61.dll' in ffmpeg['files'] + assert 'licenses/ffmpeg/ffmpeg/COPYING.GPLv3' in ffmpeg['files'] + assert 'licenses/ffmpeg/x264/COPYING' in ffmpeg['files'] + write(build / 'vibechat.exe', original_exe) + shutil.rmtree(gui / 'ffmpeg') + (build / 'avcodec-61.dll').unlink() + print('PASS: custom FFmpeg and its static codec license notices are included') + + # Исполняем настоящий GUI build.sh: пакетирование обязано быть частью успешной Release-сборки. + write(build / 'CMakeCache.txt', 'CMAKE_GENERATOR:INTERNAL=Ninja\n') + write(commands / 'cmake', '''#!/bin/bash +set -eu +printf '%s\\n' "$*" >> "$CMAKE_CALLS" +''', executable=True) + shutil.copy(commands / 'objdump', prefix / 'bin/objdump') + write(gui / 'setup_firewall.bat', 'fixture\n') + write(gui / 'rlottie_build/librlottie.dll', 'DLL Name: KERNEL32.dll\n') + for name in ('libstdc++-6.dll', 'libgcc_s_seh-1.dll', 'libwinpthread-1.dll', 'zlib1.dll', 'libcrypto-3.dll'): + write(prefix / 'bin' / name, 'DLL Name: KERNEL32.dll\n') + original_exe += f'DLL Name: {name}\n' + write(build / 'vibechat.exe', original_exe) + calls = root / 'cmake-calls.log' + built = subprocess.run(['bash', str(gui / 'build.sh'), '--installer', '-j2'], cwd=gui, + env={**environment, 'CMAKE_CALLS': str(calls)}, text=True, capture_output=True) + assert built.returncode == 0, built.stdout + built.stderr + assert '-DCMAKE_BUILD_TYPE=Release' in calls.read_text() + assert 'installer created:' in built.stdout and 'Build SUCCESS' in built.stdout + failed = subprocess.run(['bash', str(gui / 'build.sh'), '--installer'], cwd=gui, + env={**environment, 'CMAKE_CALLS': str(calls), 'COMPILER_FAIL': '1'}, text=True, capture_output=True) + assert failed.returncode != 0 and 'Build SUCCESS' not in failed.stdout + print('PASS: GUI installer flag selects Release and propagates packaging errors') + + shutil.rmtree(root) +except Exception: + print(f'ERROR: installer fixture retained: {root}') + raise