From 87b8d0f3d6e8a4432eccfad67ffa1d6296ca4597 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Sun, 17 May 2026 17:59:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(ci):=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D1=89=D0=B8=D0=BA=20?= =?UTF-8?q?=E2=80=94=20CSS-=D0=BB=D0=B8=D0=BD=D1=82=D0=B5=D1=80=20+=20smok?= =?UTF-8?q?e=20API=20+=20GitHub=20Actions=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/test.md | 22 ++++ .github/workflows/ci.yml | 51 ++++++++ tests/lint_css.py | 182 +++++++++++++++++++++++++++++ tests/smoke_api.py | 247 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 502 insertions(+) create mode 100644 .claude/commands/test.md create mode 100644 .github/workflows/ci.yml create mode 100644 tests/lint_css.py create mode 100644 tests/smoke_api.py diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 0000000..1de78ef --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,22 @@ +# Тестировщик — локальный запуск перед коммитом + +Запусти оба теста и выдай сводный отчёт с замечаниями. + +## Шаг 1 — CSS-линтер +```bash +python tests/lint_css.py +``` + +## Шаг 2 — Smoke-тесты API +```bash +python tests/smoke_api.py +``` + +## Шаг 3 — Сводный отчёт + +Выведи: +- ✅ или ❌ по каждому тесту +- Список замечаний к устранению (если есть) +- Вывод: **МОЖНО КОММИТИТЬ** или **НЕЛЬЗЯ — исправь замечания** + +Не коммить пока все тесты не зелёные. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fbc61ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI — Lint + Smoke Tests + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + lint-css: + name: CSS-линтер + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Запуск CSS-линтера + run: python tests/lint_css.py + + smoke-api: + name: Smoke-тесты API + runs-on: ubuntu-latest + # Запускаем только на пушах в master (не на PR — там нет деплоя) + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + # Ждём деплоя GitHub Pages (если были изменения в miniapp/) + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Пауза после деплоя Pages (60s) + run: sleep 60 + - name: Запуск smoke-тестов + run: python tests/smoke_api.py --url https://api.wasrusgen1.pro + + report: + name: Сводный отчёт + runs-on: ubuntu-latest + needs: [lint-css, smoke-api] + if: always() + steps: + - name: Итог + run: | + echo "CSS-линтер: ${{ needs.lint-css.result }}" + echo "Smoke API: ${{ needs.smoke-api.result }}" + if [[ "${{ needs.lint-css.result }}" == "failure" || "${{ needs.smoke-api.result }}" == "failure" ]]; then + echo "❌ CI упал — есть замечания к устранению" + exit 1 + fi + echo "✅ CI прошёл" diff --git a/tests/lint_css.py b/tests/lint_css.py new file mode 100644 index 0000000..6bb5bb0 --- /dev/null +++ b/tests/lint_css.py @@ -0,0 +1,182 @@ +""" +CSS-линтер для miniapp/assets/ +Запуск: python tests/lint_css.py +Возвращает exit code 1 если найдены проблемы. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).parent.parent / "miniapp" / "assets" + +ISSUES = [] + + +def issue(file: str, line: int, msg: str): + ISSUES.append(f" ❌ {file}:{line} {msg}") + + +# ─── Правила ──────────────────────────────────────────────────────────────── + +# 1. Запрещённые паттерны скрытия текста +FORBIDDEN_COLOR_PATTERNS = [ + ( + # Только свойство color:, но НЕ -webkit-tap-highlight-color: + r"(? 0: + brace_depth += stripped.count("{") - stripped.count("}") + block_lines.append(stripped) + if brace_depth == 0: + _lint_block(fname, current_selector, block_lines, block_start, i) + current_selector = "" + block_lines = [] + + # Построчные проверки + for pattern, msg in FORBIDDEN_COLOR_PATTERNS: + if re.search(pattern, stripped, re.IGNORECASE): + issue(fname, i, msg) + + +def _lint_block(fname, selector, block_lines, start_line, end_line): + block_text = " ".join(block_lines) + + # Правило 1: color:var(--card) в текстовых классах + if re.search(r"color\s*:\s*var\(--card\)", block_text, re.IGNORECASE): + for pat in TEXT_CLASSES: + if re.search(pat, selector): + issue( + fname, start_line, + f"`{selector.strip()}` использует color:var(--card) — " + f"не совпадает с фоном карточки в тёмных темах. Используй HEX или opacity:0", + ) + + # Правило 2: color:var(--paper) в текстовых классах + if re.search(r"color\s*:\s*var\(--paper\)", block_text, re.IGNORECASE): + for pat in TEXT_CLASSES: + if re.search(pat, selector): + issue( + fname, start_line, + f"`{selector.strip()}` использует color:var(--paper) — " + f"может не совпасть с фоном в других темах. Используй HEX", + ) + + # Правило 3: обязательные классы должны иметь явный color: + has_color = bool(re.search(r"\bcolor\s*:", block_text)) + for pat in REQUIRED_COLOR_CLASSES: + if re.search(pat, selector) and not has_color: + issue( + fname, start_line, + f"`{selector.strip()}` не имеет явного color: — " + f"текст унаследует цвет от body и может быть невидим или слишком ярким", + ) + + +# ─── Проверка версий в index.html ────────────────────────────────────────── + +def lint_versions(): + index = Path(__file__).parent.parent / "miniapp" / "index.html" + if not index.exists(): + return + + text = index.read_text(encoding="utf-8") + versions = {} + + # Собираем версии из href/src + for m in re.finditer(r'(?:href|src)="assets/([^"]+)\?v=([^"]+)"', text): + fname, ver = m.group(1), m.group(2) + versions[fname] = ver + + # Проверяем что CSS и JS версии не слишком старые (> 30 дней назад) + from datetime import datetime + today_str = datetime.now().strftime("%Y%m%d") + for fname, ver in versions.items(): + if re.match(r"^\d{8}", ver): + file_date = ver[:8] + # Сравниваем как строки (работает для YYYYMMDD) + days_diff = (datetime.strptime(today_str, "%Y%m%d") - + datetime.strptime(file_date, "%Y%m%d")).days + if days_diff > 30: + ISSUES.append( + f" ⚠️ index.html: {fname} версия «{ver}» устарела " + f"на {days_diff} дней — возможно забыли обновить версию при последнем изменении" + ) + + # Форматы версии должны совпадать с шаблоном YYYYMMDDx + for fname, ver in versions.items(): + if not re.match(r"^\d{8}[a-z]$", ver): + ISSUES.append( + f" ⚠️ index.html: {fname} версия «{ver}» не соответствует формату " + f"YYYYMMDDx (например 20260517j)" + ) + + +# ─── Main ─────────────────────────────────────────────────────────────────── + +def main(): + print("🔍 CSS-линтер miniapp/assets/\n") + + css_files = list(ROOT.glob("*.css")) + if not css_files: + print(" Нет CSS-файлов для анализа.") + sys.exit(0) + + for f in sorted(css_files): + lint_file(f) + + lint_versions() + + if ISSUES: + print("Найдены проблемы:\n") + for iss in ISSUES: + print(iss) + print(f"\n🚫 Итого: {len(ISSUES)} замечание(й). Исправь перед коммитом.\n") + sys.exit(1) + else: + print("✅ Всё чисто — замечаний нет.\n") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/smoke_api.py b/tests/smoke_api.py new file mode 100644 index 0000000..aecc3c0 --- /dev/null +++ b/tests/smoke_api.py @@ -0,0 +1,247 @@ +""" +Smoke-тесты API на боевом сервере. +Тестирует только публичные/анонимные эндпоинты (без initData). +Запуск: python tests/smoke_api.py [--url https://api.wasrusgen1.pro] +""" + +import sys +import json +import urllib.request +import urllib.error +import argparse +import time + +BASE_URL = "https://api.wasrusgen1.pro" + +RESULTS = [] + + +def check(name: str, ok: bool, detail: str = ""): + icon = "✅" if ok else "❌" + msg = f" {icon} {name}" + if detail: + msg += f" ({detail})" + RESULTS.append((ok, msg)) + print(msg) + + +def get(path: str, timeout=10): + url = f"{BASE_URL}{path}" + try: + req = urllib.request.Request(url, headers={"User-Agent": "zov-smoke/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + body = r.read().decode() + return r.status, body + except urllib.error.HTTPError as e: + return e.code, e.read().decode() + except Exception as e: + return None, str(e) + + +def post(path: str, payload: dict, timeout=10): + url = f"{BASE_URL}{path}" + data = json.dumps(payload).encode() + try: + req = urllib.request.Request( + url, data=data, + headers={"Content-Type": "application/json", "User-Agent": "zov-smoke/1.0"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as r: + body = r.read().decode() + return r.status, body + except urllib.error.HTTPError as e: + return e.code, e.read().decode() + except Exception as e: + return None, str(e) + + +# ─── Тесты ────────────────────────────────────────────────────────────────── + +def test_healthz(): + status, body = get("/healthz") + check("GET /healthz → 200", status == 200, f"status={status}") + + +def test_root(): + status, body = get("/") + check("GET / → 200", status == 200, f"status={status}") + + +def test_me_no_auth(): + """Без initData должен вернуть ошибку аутентификации, но не 500.""" + status, body = post("/api/me", {"initData": "", "role": "manager"}) + try: + data = json.loads(body) + has_error_field = "error" in data + except Exception: + has_error_field = False + check( + "POST /api/me без initData → ошибка аутентификации (не 500)", + status in (200, 400, 403) and has_error_field, + f"status={status} error={data.get('error', '?') if has_error_field else body[:60]}", + ) + + +def test_clients_no_auth(): + status, body = post("/api/clients", {"initData": ""}) + try: + data = json.loads(body) + ok = "error" in data and status != 500 + except Exception: + ok = False + check("POST /api/clients без initData → auth-ошибка (не 500)", ok, f"status={status}") + + +def test_assembly_list_no_auth(): + status, body = post("/api/assembly_list", {"initData": ""}) + try: + data = json.loads(body) + ok = "error" in data and status != 500 + except Exception: + ok = False + check("POST /api/assembly_list без initData → auth-ошибка (не 500)", ok, f"status={status}") + + +def test_measurement_request_no_auth(): + status, body = post("/api/measurement_request", { + "initData": "", + "client_name": "Тест", + "client_phone": "79001234567", + }) + try: + data = json.loads(body) + ok = "error" in data and status != 500 + except Exception: + ok = False + check("POST /api/measurement_request без initData → auth-ошибка (не 500)", ok, f"status={status}") + + +def test_assembly_create_no_auth(): + status, body = post("/api/assembly_create", { + "initData": "", + "client_name": "Тест", + "address": "Тест", + "scope_of_work": "Тест", + }) + try: + data = json.loads(body) + ok = "error" in data and status != 500 + except Exception: + ok = False + check("POST /api/assembly_create без initData → auth-ошибка (не 500)", ok, f"status={status}") + + +def test_proposal_list_no_auth(): + status, body = post("/api/proposal_list", {"initData": ""}) + try: + data = json.loads(body) + ok = "error" in data and status != 500 + except Exception: + ok = False + check("POST /api/proposal_list без initData → auth-ошибка (не 500)", ok, f"status={status}") + + +def test_staff_list_no_auth(): + status, body = post("/api/staff_list", {"initData": "", "role": "measurer"}) + try: + data = json.loads(body) + # staff_list может вернуть пустой список без аутентификации — это ок + ok = status != 500 + except Exception: + ok = False + check("POST /api/staff_list → не 500", ok, f"status={status}") + + +def test_photo_missing(): + status, body = get("/api/photo/nonexistent_id/nonexistent.jpg") + check( + "GET /api/photo/несуществующий → 404 (не 500)", + status == 404, + f"status={status}", + ) + + +def test_github_pages(): + """Проверяем что MiniApp доступен на GitHub Pages.""" + import urllib.request + url = "https://wasrusgen.github.io/zov-tech/index.html" + try: + req = urllib.request.Request(url, headers={"User-Agent": "zov-smoke/1.0"}) + with urllib.request.urlopen(req, timeout=15) as r: + body = r.read().decode() + has_app = 'id="app"' in body + check( + "GitHub Pages MiniApp доступен", + r.status == 200 and has_app, + f"status={r.status} has_app={has_app}", + ) + except Exception as e: + check("GitHub Pages MiniApp доступен", False, str(e)) + + +def test_miniapp_css_version(): + """CSS в index.html имеет версию (не закешируется по-старому).""" + import urllib.request + import re + url = "https://wasrusgen.github.io/zov-tech/index.html" + try: + req = urllib.request.Request(url, headers={"User-Agent": "zov-smoke/1.0"}) + with urllib.request.urlopen(req, timeout=15) as r: + body = r.read().decode() + has_version = bool(re.search(r'styles\.css\?v=\d{8}[a-z]', body)) + check( + "index.html: styles.css имеет версию вида ?v=YYYYMMDDx", + has_version, + f"found={has_version}", + ) + except Exception as e: + check("index.html: проверка версии", False, str(e)) + + +# ─── Main ─────────────────────────────────────────────────────────────────── + +def main(): + global BASE_URL + parser = argparse.ArgumentParser() + parser.add_argument("--url", default=BASE_URL, help="Base URL бэкенда") + args = parser.parse_args() + BASE_URL = args.url.rstrip("/") + + print(f"🔥 Smoke-тесты → {BASE_URL}\n") + t0 = time.time() + + test_healthz() + test_root() + test_me_no_auth() + test_clients_no_auth() + test_assembly_list_no_auth() + test_measurement_request_no_auth() + test_assembly_create_no_auth() + test_proposal_list_no_auth() + test_staff_list_no_auth() + test_photo_missing() + test_github_pages() + test_miniapp_css_version() + + elapsed = time.time() - t0 + passed = sum(1 for ok, _ in RESULTS if ok) + failed = len(RESULTS) - passed + + print(f"\n{'─'*50}") + print(f" Итого: {passed} пройдено / {failed} упало ({elapsed:.1f}s)") + + if failed: + print(f"\n🚫 Замечания к устранению:") + for ok, msg in RESULTS: + if not ok: + print(msg) + print() + sys.exit(1) + else: + print("\n✅ Все тесты прошли.\n") + sys.exit(0) + + +if __name__ == "__main__": + main()