2026-03-19 21:53:31 +02:00
|
|
|
|
"""
|
|
|
|
|
|
KIN-122 regression: rebuild-frontend.sh должен запускать npm install
|
|
|
|
|
|
перед npm run build, если package.json изменился (новее node_modules).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
2026-03-19 21:55:53 +02:00
|
|
|
|
import shutil
|
2026-03-19 21:53:31 +02:00
|
|
|
|
import stat
|
2026-03-19 21:55:53 +02:00
|
|
|
|
import subprocess
|
|
|
|
|
|
import time
|
2026-03-19 21:53:31 +02:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-03-19 21:55:53 +02:00
|
|
|
|
import pytest
|
|
|
|
|
|
|
2026-03-19 21:53:31 +02:00
|
|
|
|
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 должен быть безусловным вызовом"
|
|
|
|
|
|
)
|
2026-03-19 21:56:57 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Функциональные тесты: реальный запуск скрипта с 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})"
|
|
|
|
|
|
)
|