""" KIN-122 regression: rebuild-frontend.sh должен запускать npm install перед npm run build, если package.json изменился (новее node_modules). """ import os import shutil import stat import subprocess import time from pathlib import Path import pytest SCRIPT_PATH = Path(__file__).parent.parent / "scripts" / "rebuild-frontend.sh" class TestKIN122RebuildFrontendNpmInstall: """Структурные тесты: скрипт содержит условный npm install перед npm run build.""" def test_script_exists(self): assert SCRIPT_PATH.is_file(), f"rebuild-frontend.sh not found at {SCRIPT_PATH}" def test_script_is_executable(self): mode = SCRIPT_PATH.stat().st_mode assert mode & stat.S_IXUSR, "rebuild-frontend.sh должен быть исполняемым" def test_script_contains_npm_install_conditional(self): """Скрипт должен содержать условный блок npm install (KIN-122).""" content = SCRIPT_PATH.read_text() assert "npm install" in content, ( "rebuild-frontend.sh должен содержать 'npm install'" ) def test_script_npm_install_guarded_by_condition(self): """npm install должен быть внутри if-блока, а не вызываться безусловно.""" content = SCRIPT_PATH.read_text() lines = content.splitlines() # Найти строку с npm install npm_install_line_idx = next( (i for i, line in enumerate(lines) if "npm install" in line and "if" not in line), None, ) assert npm_install_line_idx is not None, "Строка с 'npm install' не найдена" # Должен быть if-блок выше preceding = "\n".join(lines[:npm_install_line_idx]) assert "if" in preceding, ( "npm install должен быть внутри условного блока" ) def test_script_checks_node_modules_existence(self): """Условие должно проверять наличие node_modules.""" content = SCRIPT_PATH.read_text() assert "node_modules" in content, ( "Скрипт должен проверять наличие node_modules" ) def test_script_checks_package_json_mtime(self): """Условие должно сравнивать mtime package.json с node_modules (флаг -nt).""" content = SCRIPT_PATH.read_text() assert "-nt" in content, ( "Скрипт должен использовать '-nt' для сравнения mtime package.json и node_modules" ) def test_npm_install_before_npm_run_build(self): """npm install должен стоять раньше npm run build в скрипте.""" content = SCRIPT_PATH.read_text() install_pos = content.find("npm install") build_pos = content.find("npm run build") assert install_pos != -1, "npm install не найден в скрипте" assert build_pos != -1, "npm run build не найден в скрипте" assert install_pos < build_pos, ( "npm install должен стоять раньше npm run build" ) def test_npm_run_build_always_runs(self): """npm run build должен вызываться вне условного блока — всегда выполняется.""" content = SCRIPT_PATH.read_text() lines = content.splitlines() # Найти строку с npm run build (не внутри if-блока) # Ищем строку, которая содержит "npm run build" и не является частью if-условия build_lines = [ line for line in lines if "npm run build" in line and line.strip().startswith("npm run build") ] assert len(build_lines) >= 1, ( "npm run build должен быть безусловным вызовом" ) # --------------------------------------------------------------------------- # Функциональные тесты: реальный запуск скрипта с mock npm # Покрывают acceptance criteria KIN-122: # 1. npm install срабатывает автоматически если package.json изменился # 2. npm install НЕ запускается если package.json не менялся # --------------------------------------------------------------------------- @pytest.fixture() def fake_frontend_env(tmp_path): """ Создаёт временную структуру директорий, копирует скрипт туда, подкладывает mock npm который пишет свои вызовы в лог-файл. Структура: tmp_path/ scripts/rebuild-frontend.sh ← копия скрипта web/frontend/ ← FRONTEND_DIR bin/npm ← mock npm (логирует вызовы) npm_calls.log ← лог вызовов (создаётся mock npm) """ scripts_dir = tmp_path / "scripts" scripts_dir.mkdir() frontend_dir = tmp_path / "web" / "frontend" frontend_dir.mkdir(parents=True) (frontend_dir / "package.json").write_text('{"name": "test-kin-122", "scripts": {"build": "echo build"}}') # Копируем скрипт в temp-окружение script_copy = scripts_dir / "rebuild-frontend.sh" shutil.copy(SCRIPT_PATH, script_copy) script_copy.chmod(0o755) # Mock npm: записывает все свои аргументы в лог-файл calls_log = tmp_path / "npm_calls.log" bin_dir = tmp_path / "bin" bin_dir.mkdir() mock_npm = bin_dir / "npm" mock_npm.write_text( f'#!/bin/bash\necho "$@" >> "{calls_log}"\n' ) mock_npm.chmod(0o755) return { "script": script_copy, "frontend_dir": frontend_dir, "bin_dir": bin_dir, "calls_log": calls_log, } def _run_script(env_data): """Запускает rebuild-frontend.sh с mock npm в PATH.""" result = subprocess.run( [str(env_data["script"])], env={**os.environ, "PATH": f"{env_data['bin_dir']}:{os.environ.get('PATH', '')}"}, capture_output=True, text=True, ) return result def _get_npm_calls(env_data): """Возвращает список аргументов из всех вызовов mock npm.""" log = env_data["calls_log"] if not log.exists(): return [] return log.read_text().strip().splitlines() class TestKIN122NpmInstallBehaviorFunctional: """ Функциональные тесты: реальный запуск скрипта в изолированном окружении. Acceptance criteria: хук срабатывает автоматически без ручного вмешательства, не замедляет pipeline если package.json не менялся. """ def test_npm_install_runs_when_node_modules_missing(self, fake_frontend_env): """AC-1: npm install вызывается автоматически если node_modules отсутствует.""" # node_modules не создан — условие ! -d node_modules истинно _run_script(fake_frontend_env) calls = _get_npm_calls(fake_frontend_env) assert any("install" in c for c in calls), ( f"npm install должен вызваться при отсутствии node_modules. Вызовы: {calls}" ) def test_npm_install_runs_when_package_json_newer_than_node_modules(self, fake_frontend_env): """AC-1: npm install вызывается если package.json новее node_modules (изменились зависимости).""" frontend = fake_frontend_env["frontend_dir"] # Создаём node_modules со старым mtime node_modules = frontend / "node_modules" node_modules.mkdir() old_time = time.time() - 200 os.utime(node_modules, (old_time, old_time)) # package.json уже имеет текущий mtime — он новее node_modules _run_script(fake_frontend_env) calls = _get_npm_calls(fake_frontend_env) assert any("install" in c for c in calls), ( f"npm install должен вызваться если package.json новее node_modules. Вызовы: {calls}" ) def test_npm_install_skipped_when_node_modules_up_to_date(self, fake_frontend_env): """AC-2: npm install НЕ вызывается если node_modules актуален (package.json не менялся).""" frontend = fake_frontend_env["frontend_dir"] # Создаём node_modules, затем делаем package.json старше node_modules = frontend / "node_modules" node_modules.mkdir() # node_modules имеет текущее время, package.json — старое old_time = time.time() - 200 package_json = frontend / "package.json" os.utime(package_json, (old_time, old_time)) _run_script(fake_frontend_env) calls = _get_npm_calls(fake_frontend_env) assert not any("install" in c for c in calls), ( f"npm install НЕ должен вызываться если node_modules актуален. Вызовы: {calls}" ) def test_npm_run_build_always_runs_regardless_of_install(self, fake_frontend_env): """AC-2: npm run build всегда вызывается — pipeline не блокируется если npm install пропущен.""" frontend = fake_frontend_env["frontend_dir"] # Сценарий: node_modules актуален, npm install не нужен node_modules = frontend / "node_modules" node_modules.mkdir() old_time = time.time() - 200 os.utime(frontend / "package.json", (old_time, old_time)) _run_script(fake_frontend_env) calls = _get_npm_calls(fake_frontend_env) assert any("run" in c and "build" in c for c in calls), ( f"npm run build должен вызываться всегда, даже без npm install. Вызовы: {calls}" ) def test_npm_run_build_runs_after_npm_install(self, fake_frontend_env): """AC-1: npm run build запускается после npm install (оба вызова в правильном порядке).""" # node_modules отсутствует — сработают оба: npm install и npm run build _run_script(fake_frontend_env) calls = _get_npm_calls(fake_frontend_env) install_calls = [c for c in calls if "install" in c] build_calls = [c for c in calls if "run" in c and "build" in c] assert install_calls, f"npm install не найден в вызовах: {calls}" assert build_calls, f"npm run build не найден в вызовах: {calls}" install_idx = calls.index(install_calls[0]) build_idx = calls.index(build_calls[0]) assert install_idx < build_idx, ( f"npm install (pos {install_idx}) должен стоять перед npm run build (pos {build_idx})" )