Compare commits

...

15 Commits

Author SHA1 Message Date
63b829471d fix: support standard Chromium-GOST installer path (Chromium\Application\chrome.exe) 2026-09-03 15:55:18 +03:00
63194d1824 Test commit 2026-09-03 15:40:00 +03:00
21109221bc feat: add codegen.py for visual test recording with GOST TLS support 2026-09-03 14:29:36 +03:00
fdafff1e3f docs: update Quick Start and Test Execution sections in README.md 2026-09-03 14:24:53 +03:00
162445481b fix: add root Chromium-Gost path candidate and safe console error encoding 2026-09-03 14:07:53 +03:00
cdca9d6003 feat: implement universal runner for standard and GOST TLS
- Add smart two-phase TLS detection with caching in tests/utils/tls.py

- Add auto-discovery for Chromium-GOST and Yandex.Browser

- Add --tls-mode and --ignore-https-errors CLI options

- Update browser_page context with ignore_https_errors and unified viewport

- Add unit tests in tests/utils/test_tls.py

- Update README.md and .env.example
2026-09-03 00:02:36 +03:00
f511102289 feat: customize HTML report Environment block and title
conftest.py (корневой, новый):
  - pytest_sessionstart — заменяет блок Environment:
    Проект / Портал / Окружение / Браузер / Тестировщик
  - pytest_html_report_title — заголовок 'Отчёт E2E тестирования'
  - Использует config.stash[metadata_key] (правильный API
    для pytest-metadata 3.x + pytest-html 4.x)
  - Fallback на config._metadata для старых версий

tests/conftest.py:
  - Убраны дублирующие хуки (перенесены в корневой conftest)

Переменные окружения для кастомизации:
  TEST_ENV     — название среды (по умолчанию 'Production')
  TESTER_NAME  — имя тестировщика (по умолчанию 'QA Team')
2026-09-02 10:42:51 +03:00
d156fa0664 docs: add Playwright Codegen section to README
Описан workflow визуальной записи тестов:
- Команды запуска codegen (с сохранением в файл и без)
- Порядок работы: запуск → авторизация → запись → адаптация
- Полный пример: сырой вывод Codegen → класс с шагами
- Таблица преобразований: что убирается и что добавляется
2026-09-01 23:58:41 +03:00
932d0594a9 docs: add detailed README with run commands and test creation guide
Описаны:
- Структура проекта и иерархия тестов
- Быстрый старт (venv, зависимости, .env)
- Команды запуска: все тесты / по роли / по БП / по шагу
- Полезные флаги pytest (headed/headless, slowmo, -k, --co)
- Шаблон создания нового бизнес-процесса вручную
- Правила именования (папки, файлы, классы, методы, маркеры)
- Инструкция по добавлению ФЛК-генератора
- Инструкция по добавлению статического артефакта
2026-09-01 23:55:23 +03:00
3df40240e7 feat: add vetstation role tests (BP01 Dashboard navigation)
Создана структура тестов для роли 'Ветеринарная станция' по той же
архитектуре что и 01_vetdept.

tests/02_vetstation/
  conftest.py                   — fixtures_dir, shared_state
  fixtures/images/.gitkeep      — папка для фото артефактов
  fixtures/docs/.gitkeep        — папка для документов
  BP01_dashboard/
    test_bp01_dashboard.py      — class TestBP01Dashboard (7 шагов)

BP01 покрывает:
  s01 — Открыть главную страницу (Статистика)
  s02 — Проверить заголовок организации
  s03 — Проверить блок статистики регистрации животных
  s04 — Проверить таблицу последних зарегистрированных особей
  s05 — Перейти в раздел Животные
  s06 — Перейти в раздел Владельцы животных (URL с regex: /owners?type=individual)
  s07 — Перейти в раздел Единый реестр

Удалён старый CodeGen-тест 002_login_test.py
2026-09-01 22:46:27 +03:00
255f0d46b9 feat: test data generators (ФЛК) and static fixtures structure
Добавлена инфраструктура для тестовых данных:

tests/testdata/generators.py — генераторы случайных данных по ФЛК:
  - Раздел 'Животное': gen_animal_name, gen_birth_date
  - Раздел 'Маркировка': gen_microchip (ISO 11784/85), gen_tattoo
  - Раздел 'Владелец': gen_owner_full_name, gen_owner_phone, gen_owner_email
  Каждая функция содержит описание ФЛК правил и пример вывода.
  Файл содержит подробное руководство по добавлению новых генераторов.

tests/01_vetdept/fixtures/ — папка для статических артефактов роли:
  fixtures/images/  ← фото животных
  fixtures/docs/    ← PDF, сертификаты

tests/01_vetdept/conftest.py — добавлена фикстура fixtures_dir (Path),
  возвращает путь к папке артефактов для использования в тестах.

requirements.txt — добавлен faker (генерация русскоязычных данных)
2026-09-01 21:32:49 +03:00
02176b5ccd feat: show step docstrings in pytest-html report Test column
Добавлен хук pytest_runtest_makereport в tests/conftest.py.
Заменяет технический nodeid (путь к файлу::класс::метод[chromium])
на первую строку docstring тестового метода.

Результат в отчёте:
  До:  tests/01_vetdept/BP01_navigation/test_bp01_navigation.py::TestBP01Navigation::test_s01_open_dashboard[chromium]
  После: Шаг 1: Открыть главную страницу (Dashboard)

Правило: что написано в docstring метода — то отображается в отчёте.
2026-09-01 21:11:52 +03:00
e718bf48d4 refactor: hierarchical test architecture (Project > Role > BP > Steps)
Реструктурированы тесты под иерархию Проект > Личный кабинет > БП > Шаги.

Архитектура:
- Бизнес-процесс = Python-класс, шаг = метод класса
- Шаги именуются test_s01_*, test_s02_* и т.д.
- БП именуются BP01_, BP02_ и т.д., каждый в своей папке

Передача данных:
- browser_page (scope=class) — один браузер на весь БП, авторизация 1 раз
- bp_state (scope=class) — словарь для передачи данных между шагами одного БП
- shared_state (scope=session) — словарь для передачи между разными БП

Зависимости шагов (pytest-dependency):
- Если шаг N упал, шаги N+1... получают статус SKIPPED автоматически
- Имена зависимостей: BP01::s01, BP01::s02 и т.д.

Изменения файлов:
- tests/conftest.py: добавлены browser_page и bp_state, вынесены
  вспомогательные функции _get_role и _login, сохранена role_page
  (deprecated) для обратной совместимости
- tests/01_vetdept/conftest.py: новый, содержит shared_state
- tests/01_vetdept/BP01_navigation/: новая папка с тестом навигации
- tests/01_vetdept/001_login_test.py: удалён (переписан в BP01)
- requirements.txt: добавлен pytest-dependency
- pytest.ini: добавлен маркер dependency
2026-08-31 17:37:21 +03:00
c47aeff07d chore: upgrade pytest-html 3.x -> 4.x, fix pytest.ini
- Upgrade pytest-html 3.2.0 to 4.x (совместима с pytest 9.1)
- Убрать пин версии pytest-html==3.2.0 в requirements.txt
- Перенести --html флаг из ручного запуска в pytest.ini addopts,
  чтобы отчёт генерировался автоматически при каждом запуске pytest
- Убрать html_report_dir из pytest.ini (не поддерживается, вызывал
  PytestConfigWarning: Unknown config option)
- Исправить опечатку в маркере vetstation
- Добавить html-report/ и report.html в .gitignore
2026-08-31 11:27:54 +03:00
642ee27588 security: move credentials to .env file
- Remove hardcoded logins/passwords from conftest.py
- Load credentials via python-dotenv from .env (excluded from git)
- Add BASE_URL env variable for easy environment switching
- Add .env.example template for new contributors
- Add validation: clear error message if .env is not filled
- Add python-dotenv to requirements.txt
2026-08-28 10:39:08 +03:00
25 changed files with 1728 additions and 99 deletions

47
.env.example Normal file
View File

@ -0,0 +1,47 @@
# ============================================================
# ШАБЛОН .env — скопируй в .env и заполни реальными данными
# Команда: cp .env.example .env
# ============================================================
# Роль: Управление ветеринарии
VETDEPT_LOGIN=
VETDEPT_PASSWORD=
# Роль: Ветеринарная станция
VETSTATION_LOGIN=
VETSTATION_PASSWORD=
# Роль: Администрация (префектура)
PREFECTURE_LOGIN=
PREFECTURE_PASSWORD=
# Базовый URL портала
BASE_URL=https://vet.goznak.ru
# ---------------------------------------------------------------------------
# Режим TLS и браузер (проверяется автоматически при старте тестов)
# ---------------------------------------------------------------------------
# Режим TLS: auto (по умолчанию), gost (принудительно ГОСТ TLS), standard (обычный TLS)
# Также можно передать флаг в pytest: pytest --tls-mode=gost
TLS_MODE=auto
# Вариант 1 (автозапуск): путь к исполняемому файлу Chromium GOST / Яндекс.Браузер
# ОПЦИОНАЛЬНО: если браузер установлен в стандартный каталог (Program Files, AppData),
# он будет найден автоматически без заполнения этой строки.
# Скачать Chromium GOST: https://github.com/deemru/chromium-gost/releases
CHROMIUM_GOST_PATH=
# Вариант 2: порт CDP для подключения к предзапущенному Chromium GOST
# Запуск: chrome.exe --remote-debugging-port=9222 --no-first-run
CHROMIUM_GOST_CDP_PORT=9222
# ---------------------------------------------------------------------------
# Кастомизация отчёта
# ---------------------------------------------------------------------------
# Название среды тестирования (отображается в блоке Environment отчёта)
TEST_ENV=Production
# Имя тестировщика (отображается в блоке Environment отчёта)
TESTER_NAME=QA Team

2
.gitignore vendored
View File

@ -174,3 +174,5 @@ cython_debug/
# PyPI configuration file
.pypirc
# Test reports
html-report/

508
README.md
View File

@ -1,2 +1,508 @@
# e2e_Pets
# e2e_Pets — End-to-End тесты портала учёта животных
Автоматизированное тестирование портала [vet.goznak.ru](https://vet.goznak.ru)
на базе **Playwright + pytest**.
---
## Содержание
- [Структура проекта](#структура-проекта)
- [Быстрый старт](#быстрый-старт)
- [Запуск тестов](#запуск-тестов)
- [Поддержка Стандартного и ГОСТ TLS](#поддержка-стандартного-и-гост-tls)
- [Создание нового теста](#создание-нового-теста)
- [Добавление генератора данных (ФЛК)](#добавление-генератора-данных-флк)
- [Добавление статического артефакта](#добавление-статического-артефакта)
- [Создание теста через визуальный рекордер (Codegen)](#создание-теста-через-визуальный-рекордер-codegen)
---
## Структура проекта
```
e2e_Pets/
├── .env # Учётные данные и настройки среды (не в git)
├── .env.example # Шаблон .env
├── conftest.py # Корневой conftest (CLI-флаги, HTML-отчёт и метаданные)
├── pytest.ini # Конфигурация pytest
├── requirements.txt # Зависимости
└── tests/
├── conftest.py # Глобальные фикстуры (browser_page, bp_state, _auto_browser)
├── testdata/
│ └── generators.py # Генераторы случайных данных по ФЛК
├── utils/
│ ├── tls.py # Детектор TLS и адаптивный менеджер браузеров
│ └── test_tls.py # Модульные тесты механизмов TLS
├── 01_vetdept/ # ЛК: Управление ветеринарии
│ ├── conftest.py # fixtures_dir, shared_state
│ ├── fixtures/ # Статические артефакты (фото, PDF)
│ └── BP01_navigation/ # Бизнес-процесс 01
│ └── test_bp01_navigation.py
└── 02_vetstation/ # ЛК: Ветеринарная станция
├── conftest.py
├── fixtures/
└── BP01_dashboard/ # Бизнес-процесс 01
└── test_bp01_dashboard.py
```
**Иерархия тестов:** Проект → ЛК (роль) → БП (`BP01_...`) → Шаги (`test_s01_...`, `test_s02_...`)
---
## Быстрый старт
### 1. Клонировать репозиторий
```powershell
git clone <url>
cd e2e_Pets
```
### 2. Создать виртуальное окружение и установить зависимости
```powershell
python -m venv venv
.\venv\Scripts\pip.exe install -r requirements.txt
.\venv\Scripts\playwright.exe install chromium
```
### 3. Настроить конфигурацию и учётные данные
```powershell
Copy-Item .env.example .env
# Открыть .env и указать BASE_URL, логины и пароли ролей
```
### 4. (Опционально) Настройка ГОСТ-браузера
Если целевой портал защищён ГОСТ TLS (ГОСТ Р 34.12-2015, 34.10-2012) или вы хотите тестировать ГОСТ-контур:
1. Скачайте архив **[Chromium-GOST](https://github.com/deemru/chromium-gost/releases)** (например, `chromium-gost-...-windows-amd64.zip`).
2. Распакуйте его в стандартный каталог `%LOCALAPPDATA%\Chromium-Gost` (или `C:\Program Files\Chromium-Gost`). Механизм тестов обнаружит его **автоматически**.
3. *Альтернатива:* если браузер распакован в произвольную папку, укажите путь в `.env`:
```ini
CHROMIUM_GOST_PATH=C:\MyPrograms\chromium-gost\chrome.exe
```
4. Убедитесь, что на компьютере установлен криптопровайдер с ГОСТ-алгоритмами (КриптоПро CSP, Security Code CSP / Континент-АП).
---
## Запуск тестов
> Все команды выполняются из корня проекта (`e2e_Pets/`).
> HTML-отчёт со статусом, типом TLS и запущенным браузером автоматически формируется в `html-report/report.html`.
---
### Автоматический запуск (рекомендуемый)
По умолчанию включён режим `--tls-mode=auto`. Система перед стартом проверяет сокет целевого `BASE_URL`:
- Если сайт доступен по **стандартному TLS** → автоматически запускается встроенный **Chromium Playwright**.
- Если сайт защищён **ГОСТ TLS** → автоматически запускается **Chromium-GOST**.
```powershell
# Запуск всех тестов в авто-режиме
.\venv\Scripts\pytest.exe
```
---
### Принудительный выбор режима TLS
Если вы хотите явно зафиксировать браузер в обход автоопределения:
```powershell
# Принудительно запустить через Chromium-GOST
.\venv\Scripts\pytest.exe --tls-mode=gost
# Принудительно запустить через стандартный Chromium Playwright
.\venv\Scripts\pytest.exe --tls-mode=standard
```
---
### Запуск тестов конкретного ЛК (роли)
```powershell
# ЛК Управление ветеринарии (все бизнес-процессы)
.\venv\Scripts\pytest.exe tests/01_vetdept/
# ЛК Ветеринарная станция (все бизнес-процессы)
.\venv\Scripts\pytest.exe tests/02_vetstation/
```
---
### Запуск конкретного бизнес-процесса или шага
```powershell
# Запуск BP01 в ЛК Управление ветеринарии
.\venv\Scripts\pytest.exe tests/01_vetdept/BP01_navigation/
# Запуск только шага 1 (Dashboard)
.\venv\Scripts\pytest.exe tests/01_vetdept/BP01_navigation/ -k "s01"
# Запуск шагов 1 и 2
.\venv\Scripts\pytest.exe tests/01_vetdept/BP01_navigation/ -k "s01 or s02"
```
---
### Полезные флаги запуска
| Флаг | Описание |
|---|---|
| `-v` | Подробный вывод с отображением docstring каждого шага |
| `--tls-mode=auto` | Режим TLS: `auto` (по умолчанию), `gost` или `standard` |
| `--ignore-https-errors` | Игнорировать ошибки сертификатов SSL (по умолчанию включено) |
| `-k "BP01"` | Фильтр тестов по подстроке в имени класса или метода |
| `--co -q` | Только показать список тестов без их выполнения |
| `--slowmo 500` | Замедление действий в браузере (в мс, по умолчанию 1000мс в `pytest.ini`) |
| `-o addopts=""` | Сбросить дефолтные флаги `pytest.ini` (например, для headless-запуска в CI) |
```powershell
# Просмотр дерева тестов без запуска браузера
.\venv\Scripts\pytest.exe --co -q
# Быстрый запуск без задержки slowmo
.\venv\Scripts\pytest.exe tests/01_vetdept/ --slowmo 0
# Запуск в режиме ГОСТ с подробным выводом
.\venv\Scripts\pytest.exe tests/01_vetdept/ --tls-mode=gost -v
```
---
## Поддержка Стандартного и ГОСТ TLS
В проект встроен универсальный механизм запуска, который автоматически определяет, какой протокол шифрования используется на целевом портале (`BASE_URL`), и выбирает подходящий браузер.
### Как работает автоопределение:
1. **Двухфазная детекция**:
- Выполняется тестовое SSL-рукопожатие сокета.
- Если сайт использует стандартные алгоритмы (TLS 1.2 / TLS 1.3, AES, CHACHA20 и др.), даже при наличии самоподписанного или тестового корпоративного сертификата, система распознаёт его как **Стандартный TLS** и запускает встроенный **Chromium Playwright**.
- Если сервер требует российские криптографические алгоритмы ГОСТ (ГОСТ Р 34.12-2015 «Кузнечик»/«Магма», ГОСТ Р 34.10-2012, 28147-89), которые не поддерживаются штатным OpenSSL в Python, система идентифицирует **ГОСТ TLS**.
2. **Автопоиск браузера для ГОСТ**:
- Система автоматически сканирует стандартные каталоги установки:
- `%LOCALAPPDATA%\Chromium-Gost\Application\chrome.exe`
- `%PROGRAMFILES%\Chromium-Gost\chrome.exe` (и в `Program Files (x86)`)
- **Яндекс.Браузер** (при установленном КриптоПро CSP): `%LOCALAPPDATA%\Yandex\...`, `%PROGRAMFILES%\Yandex\...`
- Системный `PATH` (`chromium-gost`, `yandex-browser`).
- Если браузер найден в системе, тесты стартуют автоматически без необходимости вручную прописывать путь в `.env`.
3. **Варианты запуска под ГОСТ**:
- **Автозапуск (рекомендуется)**: Установить [Chromium-GOST](https://github.com/deemru/chromium-gost/releases) или Яндекс.Браузер с КриптоПро CSP. При нестандартном расположении указать в `.env`:
```ini
CHROMIUM_GOST_PATH=C:\CustomPath\chrome.exe
```
- **Подключение по CDP (если браузер уже запущен вручную)**:
```powershell
chrome.exe --remote-debugging-port=9222 --no-first-run
```
Тесты автоматически подключатся к открытому браузеру на порту `9222`.
4. **Управление режимом TLS**:
- Через аргумент CLI:
```powershell
.\venv\Scripts\pytest.exe --tls-mode=auto # Автоопределение (по умолчанию)
.\venv\Scripts\pytest.exe --tls-mode=gost # Принудительно ГОСТ
.\venv\Scripts\pytest.exe --tls-mode=standard # Принудительно Стандартный
```
- Либо через `.env`:
```ini
TLS_MODE=auto # auto | gost | standard
```
5. **Отображение в HTML-отчёте**:
- В блоке **Environment** сгенерированного отчёта `html-report/report.html` фиксируются:
- Реальный режим TLS и использованный шифр (например, `Стандартный TLS (TLSv1.3, TLS_AES_256_GCM_SHA384)`).
- Тип и путь фактически запущенного браузера (`Chromium (Playwright)` / `Chromium GOST` / `Яндекс.Браузер`).
---
## Создание нового теста
### Нужен новый бизнес-процесс?
Опиши его в свободной форме — например:
> *«Нужен тест для ветстанции: открыть список животных, применить фильтр
> по кошкам, убедиться что список отфильтровался, открыть карточку
> первого животного, проверить наличие кнопки редактирования»*
ИИ-ассистент создаст файл в нужном месте, следуя архитектуре проекта.
---
### Если хочешь создать вручную — шаблон:
#### 1. Создать папку бизнес-процесса
```powershell
# Для роли vetstation, новый бизнес-процесс BP02
mkdir tests\02_vetstation\BP02_animals
New-Item tests\02_vetstation\BP02_animals\__init__.py
```
#### 2. Создать файл теста
Имя файла: `test_bp02_<название>.py`
```python
import os
import pytest
from playwright.sync_api import Page, expect
BASE_URL = os.getenv("BASE_URL", "https://vet.goznak.ru")
@pytest.mark.vetstation # <- маркер роли
@pytest.mark.usefixtures("browser_page") # <- авторизация и браузер
class TestBP02Animals:
"""
BP02: Краткое описание бизнес-процесса
Проверяет что пользователь может:
- Шаг 1: ...
- Шаг 2: ...
"""
@pytest.mark.dependency(name="BP02::s01")
def test_s01_<название>(self, browser_page: Page, bp_state: dict):
"""Шаг 1: Описание шага — именно это появится в отчёте"""
# ... код теста ...
bp_state["key"] = "value" # передать данные следующему шагу
@pytest.mark.dependency(name="BP02::s02", depends=["BP02::s01"])
def test_s02_<название>(self, browser_page: Page, bp_state: dict):
"""Шаг 2: Описание шага"""
value = bp_state["key"] # получить данные от предыдущего шага
# ... код теста ...
```
#### 3. Правила именования
| Элемент | Формат | Пример |
|---|---|---|
| Папка БП | `BP<NN>_<описание>` | `BP02_animals` |
| Файл теста | `test_bp<NN>_<описание>.py` | `test_bp02_animals.py` |
| Класс | `TestBP<NN><Описание>` | `TestBP02Animals` |
| Метод (шаг) | `test_s<NN>_<описание>` | `test_s01_open_list` |
| Зависимость | `BP<NN>::s<NN>` | `BP02::s01` |
#### 4. Маркеры ролей
| Маркер | Роль |
|---|---|
| `@pytest.mark.vetdept` | Управление ветеринарии |
| `@pytest.mark.vetstation` | Ветеринарная станция |
| `@pytest.mark.prefecture` | Администрация (префектура) |
---
## Добавление генератора данных (ФЛК)
Файл: [`tests/testdata/generators.py`](tests/testdata/generators.py)
Содержит пошаговое руководство по добавлению нового генератора прямо в начале файла. Коротко:
```python
# Шаблон новой функции-генератора:
def gen_<сущность>_<поле>() -> str:
"""
Краткое описание.
ФЛК:
- Правило 1
- Правило 2
Пример вывода: 'AB1234'
"""
# ... реализация ...
```
Использование в тесте:
```python
from tests.testdata.generators import gen_microchip, gen_animal_name
def test_s01_fill_form(self, browser_page, bp_state):
name = gen_animal_name()
chip = gen_microchip()
browser_page.fill("#name", name)
browser_page.fill("#chip", chip)
bp_state["name"] = name # передаём следующему шагу
```
---
## Добавление статического артефакта
1. Положи файл в папку `fixtures/` нужной роли:
```
tests/01_vetdept/fixtures/images/cat_photo.jpg
```
2. Раскомментируй шаблон фикстуры в `tests/<роль>/conftest.py`:
```python
@pytest.fixture(scope="session")
def cat_photo(fixtures_dir) -> Path:
"""Фото кошки для теста загрузки."""
return fixtures_dir / "images" / "cat_photo.jpg"
```
3. Используй в тесте:
```python
def test_s02_upload(self, browser_page, bp_state, cat_photo):
browser_page.set_input_files("input[type=file]", str(cat_photo))
```
---
## Создание теста через визуальный рекордер (Codegen)
Playwright Codegen — встроенный инструмент записи действий в браузере.
Ты кликаешь, вводишь текст и переходишь по страницам — Codegen генерирует
Python-код, который воспроизводит твои действия.
### Запуск рекордера
#### Вариант 1: Универсальный запуск с поддержкой ГОСТ TLS (рекомендуемый)
Штатная команда `playwright codegen` не умеет работать со сторонними браузерами напрямую. Для записи тестов на сайтах с **ГОСТ TLS** (а также обычным TLS) используйте встроенный скрипт `codegen.py`:
```powershell
# Запуск для URL по умолчанию из .env (с автоопределением TLS и выбором браузера):
.\venv\Scripts\python.exe codegen.py
# Принудительный запуск в режиме ГОСТ TLS (через Chromium-GOST):
.\venv\Scripts\python.exe codegen.py --gost
# Запуск для произвольного адреса:
.\venv\Scripts\python.exe codegen.py https://vet.goznak.ru
```
#### Вариант 2: Штатный запуск (только для сайтов со стандартным TLS)
```powershell
# Открыть встроенный Chromium с рекордером
.\venv\Scripts\playwright.exe codegen https://vet.goznak.ru
# Открыть рекордер и сразу сохранить результат в файл
.\venv\Scripts\playwright.exe codegen https://vet.goznak.ru --output tests\codegen_draft.py
```
При запуске откроются два окна:
- **Браузер (Chromium-GOST или Playwright Chromium)** — кликайте, заполняйте формы и переходите по разделам.
- **Playwright Inspector** — окно записи, в котором в реальном времени формируется Python-код.
### Порядок работы
1. **Запусти рекордер** командой выше
2. **Авторизуйся** под нужной ролью (введи логин/пароль из `.env`)
3. **Выполни нужный бизнес-процесс** — все действия записываются автоматически
4. **Скопируй сгенерированный код** из Playwright Inspector
5. **Адаптируй код** под архитектуру проекта (см. ниже)
---
### Как адаптировать код из Codegen в архитектуру проекта
Codegen генерирует **плоский скрипт**, проект использует **класс с шагами**.
Ниже показано как преобразовать одно в другое.
#### До (вывод Codegen):
```python
from playwright.sync_api import Playwright, sync_playwright, expect
def run(playwright: Playwright) -> None:
browser = playwright.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("https://vet.goznak.ru/auth")
page.get_by_role("textbox", name="Email").fill("gz_vetstation@vet.goznak.ru")
page.get_by_role("textbox", name="Введите пароль").fill("A6wrpzpD5T")
page.get_by_role("button", name="Войти в аккаунт").click()
page.get_by_role("link", name="Животные").click()
expect(page).to_have_url("https://vet.goznak.ru/animals")
page.get_by_role("button", name="Добавить животное").click()
page.get_by_label("Кличка").fill("Мурка")
page.get_by_role("button", name="Сохранить").click()
expect(page.get_by_text("Мурка")).to_be_visible()
context.close()
browser.close()
with sync_playwright() as playwright:
run(playwright)
```
#### После (архитектура проекта):
```python
import os
import pytest
from playwright.sync_api import Page, expect
BASE_URL = os.getenv("BASE_URL", "https://vet.goznak.ru")
@pytest.mark.vetstation
@pytest.mark.usefixtures("browser_page")
class TestBP02AddAnimal:
"""
BP02: Добавление нового животного
Проверяет что пользователь может:
- Перейти в раздел Животные
- Открыть форму добавления
- Заполнить кличку и сохранить
- Убедиться что животное появилось в списке
"""
# Авторизация и логин/пароль — убраны полностью:
# этим занимается фикстура browser_page (см. tests/conftest.py)
@pytest.mark.dependency(name="BP02::s01")
def test_s01_go_to_animals(self, browser_page: Page, bp_state: dict):
"""Шаг 1: Перейти в раздел Животные"""
browser_page.get_by_role("link", name="Животные").click()
expect(browser_page).to_have_url(BASE_URL + "/animals", timeout=10000)
@pytest.mark.dependency(name="BP02::s02", depends=["BP02::s01"])
def test_s02_open_add_form(self, browser_page: Page, bp_state: dict):
"""Шаг 2: Открыть форму добавления животного"""
browser_page.get_by_role("button", name="Добавить животное").click()
expect(browser_page.get_by_label("Кличка")).to_be_visible(timeout=10000)
@pytest.mark.dependency(name="BP02::s03", depends=["BP02::s02"])
def test_s03_fill_and_save(self, browser_page: Page, bp_state: dict):
"""Шаг 3: Заполнить кличку и сохранить"""
from tests.testdata.generators import gen_animal_name
name = gen_animal_name() # случайное имя по ФЛК
browser_page.get_by_label("Кличка").fill(name)
browser_page.get_by_role("button", name="Сохранить").click()
bp_state["animal_name"] = name # передаём шагу 4
@pytest.mark.dependency(name="BP02::s04", depends=["BP02::s03"])
def test_s04_verify_in_list(self, browser_page: Page, bp_state: dict):
"""Шаг 4: Проверить что животное появилось в списке"""
expect(
browser_page.get_by_text(bp_state["animal_name"])
).to_be_visible(timeout=10000)
```
### Что изменилось при адаптации
| Было в Codegen | Стало в проекте |
|---|---|
| `browser.launch()` + `context.new_page()` | Фикстура `browser_page` (авто) |
| Явный логин/пароль в коде | Берётся из `.env` через фикстуру |
| Один плоский `def run()` | Класс с методами-шагами |
| Нет зависимостей между шагами | `@pytest.mark.dependency(depends=[...])` |
| Хардкод данных (`"Мурка"`) | Генераторы ФЛК (`gen_animal_name()`) |
| Нет передачи данных между шагами | `bp_state["key"] = value` |
| Нет отображения в отчёте | Docstring шага → колонка Test в отчёте |

125
codegen.py Normal file
View File

@ -0,0 +1,125 @@
"""
Инструмент запуска визуального рекордера Playwright Codegen
с автоматической поддержкой Стандартного и ГОСТ TLS.
Использование:
.\\venv\\Scripts\\python.exe codegen.py [URL] [опции]
Примеры:
# Автоопределение TLS для URL из .env (по умолчанию):
.\\venv\\Scripts\\python.exe codegen.py
# Запуск для конкретного URL:
.\\venv\\Scripts\\python.exe codegen.py https://vet.goznak.ru
# Принудительно через Chromium-GOST:
.\\venv\\Scripts\\python.exe codegen.py --gost
# Принудительно через стандартный Chromium:
.\\venv\\Scripts\\python.exe codegen.py --standard
"""
import argparse
import os
import sys
# pyrefly: ignore [missing-import]
from dotenv import load_dotenv
# pyrefly: ignore [missing-import]
from playwright.sync_api import sync_playwright
from tests.utils.tls import detect_tls_mode, find_gost_browser_executable
load_dotenv()
DEFAULT_URL = os.getenv("BASE_URL", "https://vet.goznak.ru")
def main():
parser = argparse.ArgumentParser(
description="Визуальный рекордер Playwright с поддержкой Chromium-GOST и стандартного Chromium."
)
parser.add_argument(
"url",
nargs="?",
default=DEFAULT_URL,
help=f"Целевой URL портала (по умолчанию из .env: {DEFAULT_URL})",
)
parser.add_argument(
"--gost",
action="store_true",
help="Принудительно запустить рекордер через Chromium-GOST",
)
parser.add_argument(
"--standard",
action="store_true",
help="Принудительно запустить рекордер через встроенный Chromium Playwright",
)
args = parser.parse_args()
# Если передан URL без схемы (например, paws.vet.goznak.ru) — добавляем https://
url = args.url
if not url.startswith("http://") and not url.startswith("https://"):
url = f"https://{url}"
# Определение режима TLS
if args.gost:
is_gost = True
tls_desc = "Принудительно (ГОСТ TLS)"
elif args.standard:
is_gost = False
tls_desc = "Принудительно (Стандартный TLS)"
else:
is_gost, tls_desc = detect_tls_mode(url)
print(f"\n{'=' * 70}")
print("ЗАПУСК ВИЗУАЛЬНОГО РЕКОРДЕРА PLAYWRIGHT")
print(f"Целевой URL: {url}")
print(f"Режим TLS: {tls_desc}")
# Включаем режим отладчика/инспектора Playwright
os.environ["PWDEBUG"] = "1"
with sync_playwright() as playwright:
if is_gost:
gost_path = find_gost_browser_executable()
if not gost_path or not os.path.exists(gost_path):
print("\n[ОШИБКА] Chromium-GOST не найден в системе!")
print("Скачайте его с https://github.com/deemru/chromium-gost/releases")
print("и распакуйте в %LOCALAPPDATA%\\Chromium-Gost,")
print("либо укажите путь в .env: CHROMIUM_GOST_PATH=...")
sys.exit(1)
print(f"Браузер: Chromium GOST22 ({gost_path})")
browser = playwright.chromium.launch(
executable_path=gost_path,
headless=False,
)
else:
print("Браузер: Chromium (Playwright)")
browser = playwright.chromium.launch(headless=False)
context = browser.new_context(
ignore_https_errors=True,
viewport={"width": 1920, "height": 1080},
)
page = context.new_page()
print(f"{'=' * 70}")
print("1. Открылись окна: Браузер и Playwright Inspector.")
print("2. В окне инспектора нажмите кнопку записи (красный кружок 'Record').")
print("3. Кликайте и заполняйте формы — код генерируется в окне инспектора.")
print("4. Для завершения просто закройте окно браузера.")
print(f"{'=' * 70}\n")
try:
page.goto(url)
except Exception as e:
print(f"Переход на {url}: {e}")
# page.pause() активирует панель рекордера/инспектора
page.pause()
context.close()
browser.close()
if __name__ == "__main__":
main()

91
conftest.py Normal file
View File

@ -0,0 +1,91 @@
"""
Корневой conftest.py — конфигурация на уровне всей сессии.
Здесь размещены хуки, которые должны работать глобально.
"""
import os
# pyrefly: ignore [missing-import]
import pytest
# pyrefly: ignore [missing-import]
from dotenv import load_dotenv
from tests.utils.tls import detect_tls_mode, tls_info_key, find_gost_browser_executable
load_dotenv()
BASE_URL = os.getenv("BASE_URL", "https://vet.goznak.ru")
# ---------------------------------------------------------------------------
# Регистрация CLI аргументов запуска
# ---------------------------------------------------------------------------
def pytest_addoption(parser):
"""Регистрирует дополнительные флаги командной строки для pytest."""
parser.addoption(
"--tls-mode",
action="store",
default="auto",
choices=["auto", "gost", "standard"],
help="Режим TLS: auto (по умолчанию — автоопределение), gost (принудительно ГОСТ TLS), standard (обычный TLS).",
)
parser.addoption(
"--ignore-https-errors",
action="store_true",
default=True,
help="Игнорировать ошибки SSL сертификатов в браузере (по умолчанию: True).",
)
# ---------------------------------------------------------------------------
# Настройка блока Environment и заголовка HTML-отчёта
# ---------------------------------------------------------------------------
def pytest_sessionstart(session):
"""
Заменяет технические данные в блоке Environment отчёта на значимые.
pytest-html 4.x читает данные через config.stash[metadata_key]
(API pytest-metadata 3.x). Наш хук изменяет именно этот stash.
"""
config = session.config
# Получаем режим TLS из CLI аргумента или .env
cli_tls_mode = config.getoption("--tls-mode", default="auto")
force_mode = None if cli_tls_mode == "auto" else cli_tls_mode
# Определяем режим TLS с кэшированием и сохраняем в stash сессии
is_gost, tls_desc = detect_tls_mode(BASE_URL, force_mode=force_mode)
config.stash[tls_info_key] = (is_gost, tls_desc)
if is_gost:
gost_exec = find_gost_browser_executable()
if gost_exec and "yandex" in gost_exec.lower():
browser_name = "Яндекс.Браузер (ГОСТ)"
else:
browser_name = "Chromium GOST"
else:
browser_name = "Chromium (Playwright)"
try:
# pyrefly: ignore [missing-import]
from pytest_metadata.plugin import metadata_key
metadata = config.stash[metadata_key]
except (ImportError, KeyError):
metadata = getattr(config, "_metadata", None)
if metadata is None:
return
# Сбрасываем технические данные от pytest-metadata
metadata.clear()
# Заполняем значимыми данными для отчёта руководителю
metadata["Проект"] = "e2e_Pets — Учёт домашних животных"
metadata["Портал"] = BASE_URL
metadata["TLS Режим"] = tls_desc
metadata["Браузер"] = browser_name
metadata["Окружение"] = os.getenv("TEST_ENV", "Production")
metadata["Тестировщик"] = os.getenv("TESTER_NAME", "QA Team")
def pytest_html_report_title(report):
"""Задаёт заголовок HTML-отчёта."""
report.title = "Отчёт E2E тестирования — Портал учёта животных"

View File

@ -1,10 +1,10 @@
[pytest]
addopts = --browser chromium --headed --slowmo 1000
html_report_dir = html-report
addopts = --browser chromium --headed --slowmo 1000 --html=html-report/report.html --self-contained-html
testpaths = tests
markers =
vetdept: тесты для роли Управление ветеринарии
vetstation: тесты для роли Ветеринарная станци
vetstation: тесты для роли Ветеринарная станция
prefecture: тесты для роли Администрация
contractor_admin: тесты для роли Администратор организации отлова
smoke: критически важные тесты для быстрого прогона
smoke: критически важные тесты для быстрого прогона
dependency: зависимость шага от предыдущего (pytest-dependency)

View File

@ -2,4 +2,7 @@ playwright
pytest
pytest-playwright
pytest-xdist
pytest-html==3.2.0
pytest-html
pytest-dependency
faker
python-dotenv

View File

@ -1,53 +0,0 @@
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.vetdept
def test_dashboard(role_page: Page):
print("\n[TEST] ТЕСТ: Проверка навигации по разделам портала")
print("=" * 60)
# Шаг 1
print("\n[1/6] Переход на главную страницу (Dashboard)...")
role_page.goto("https://vet.goznak.ru/dashboard")
print(" [OK] Страница загружена")
# Шаг 2
print("[2/6] Проверка URL главной страницы...")
expect(role_page).to_have_url("https://vet.goznak.ru/dashboard")
print(" [OK] URL соответствует ожидаемому")
# Шаг 3
print("[3/6] Проверка заголовка организации...")
expect(role_page.locator("#app")).to_contain_text("Управление Ветеринарии Гознак")
print(" [OK] Заголовок 'Управление Ветеринарии Гознак' найден")
# Шаг 4
print("[4/6] Переход в раздел 'Организации'...")
role_page.get_by_role("link", name="Организации").click()
print(" [OK] Клик выполнен, ожидание перехода...")
print(" Проверка URL раздела 'Организации'...")
expect(role_page).to_have_url("https://vet.goznak.ru/organizations", timeout=10000)
print(" [OK] URL раздела верный")
print(" Проверка заголовка страницы...")
expect(role_page.get_by_text("Подведомственные организации")).to_be_visible(timeout=10000)
print(" [OK] Заголовок 'Подведомственные организации' найден")
# Шаг 5
print("[5/6] Переход в раздел 'Заявки на отлов'...")
role_page.get_by_role("link", name="Заявки на отлов").click()
print(" [OK] Клик выполнен, ожидание перехода...")
# Шаг 6
print("[6/6] Проверка страницы 'Заявки на отлов'...")
expect(role_page).to_have_url("https://vet.goznak.ru/claims-list", timeout=10000)
print(" [OK] URL раздела верный")
expect(role_page.get_by_text("Мои заявки")).to_be_visible(timeout=10000)
print(" [OK] Заголовок 'Мои заявки' найден")
print("\n" + "=" * 60)
print("🎉 ВСЕ ПРОВЕРКИ ПРОЙДЕНУ УСПЕШНО!")
print("=" * 60)

View File

@ -0,0 +1 @@
# BP01: Навигация по разделам портала

View File

@ -0,0 +1,57 @@
import os
# pyrefly: ignore [missing-import]
import pytest
# pyrefly: ignore [missing-import]
from playwright.sync_api import Page, expect
BASE_URL = os.getenv("BASE_URL", "https://vet.goznak.ru")
@pytest.mark.vetdept
@pytest.mark.usefixtures("browser_page")
class TestBP01Navigation:
"""
BP01: Навигация по разделам портала
Проверяет что пользователь с ролью 'Управление Ветеринарии' может:
- Открыть главную страницу (Dashboard)
- Видеть заголовок своей организации
- Перейти в раздел Организации
- Перейти в раздел Заявки на отлов
"""
@pytest.mark.dependency(name="BP01::s01")
def test_s01_open_dashboard(self, browser_page: Page, bp_state: dict):
"""Шаг 1: Открыть главную страницу (Dashboard)"""
browser_page.goto(BASE_URL + "/dashboard")
expect(browser_page).to_have_url(BASE_URL + "/dashboard")
bp_state["start_url"] = browser_page.url
@pytest.mark.dependency(name="BP01::s02", depends=["BP01::s01"])
def test_s02_check_org_header(self, browser_page: Page, bp_state: dict):
"""Шаг 2: Проверить заголовок организации в интерфейсе"""
expect(browser_page.locator("#app")).to_contain_text(
"Управление Ветеринарии Гознак"
)
@pytest.mark.dependency(name="BP01::s03", depends=["BP01::s02"])
def test_s03_go_to_organizations(self, browser_page: Page, bp_state: dict):
"""Шаг 3: Перейти в раздел Организации"""
browser_page.get_by_role("link", name="Организации").click()
expect(browser_page).to_have_url(
BASE_URL + "/organizations", timeout=10000
)
expect(
browser_page.get_by_text("Подведомственные организации")
).to_be_visible(timeout=10000)
@pytest.mark.dependency(name="BP01::s04", depends=["BP01::s03"])
def test_s04_go_to_claims(self, browser_page: Page, bp_state: dict):
"""Шаг 4: Перейти в раздел Заявки на отлов"""
browser_page.get_by_role("link", name="Заявки на отлов").click()
expect(browser_page).to_have_url(
BASE_URL + "/claims-list", timeout=10000
)
expect(browser_page.get_by_text("Мои заявки")).to_be_visible(
timeout=10000
)

View File

@ -0,0 +1,63 @@
# pyrefly: ignore [missing-import]
import pytest
from pathlib import Path
# ---------------------------------------------------------------------------
# Пути к статическим артефактам роли (фото, документы, файлы)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def fixtures_dir() -> Path:
"""
Путь к папке со статическими артефактами роли vetdept.
Структура папки:
fixtures/
├── images/ ← фотографии животных, документы-сканы
└── docs/ ← PDF, сертификаты и прочие документы
Используй как базовый путь для других фикстур-артефактов:
def test_s02_upload(self, browser_page, fixtures_dir):
photo = fixtures_dir / "images" / "cat_photo.jpg"
browser_page.set_input_files("input[type=file]", str(photo))
"""
return Path(__file__).parent / "fixtures"
# ---------------------------------------------------------------------------
# Готовые фикстуры для конкретных артефактов
# Добавляй новые по мере появления новых файлов в папке fixtures/
# ---------------------------------------------------------------------------
# Пример: раскомментируй и адаптируй когда нужен конкретный файл
#
# @pytest.fixture(scope="session")
# def cat_photo(fixtures_dir) -> Path:
# """Фотография кошки для теста загрузки фото животного."""
# return fixtures_dir / "images" / "cat_photo.jpg"
# ---------------------------------------------------------------------------
# Контейнер для передачи данных между разными БП одной роли (scope=session)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def shared_state() -> dict:
"""
Словарь для передачи данных между разными бизнес-процессами
в рамках одной тестовой сессии.
Используй когда результат одного БП нужен в другом.
Например: BP01 создал заявку → BP02 работает с ней.
Пример:
# В BP01:
def test_s03_create(self, browser_page, bp_state, shared_state):
bp_state["claim_id"] = "12345"
shared_state["claim_id"] = "12345" # передаём в BP02
# В BP02:
def test_s01_open(self, browser_page, shared_state):
claim_id = shared_state["claim_id"]
"""
return {}

View File

@ -0,0 +1 @@
# Эта папка хранит PDF, сертификаты и прочие документы для тестов загрузки файлов.

View File

@ -0,0 +1,2 @@
# Эта папка хранит фотографии животных и другие изображения для тестов.
# Добавляй реальные файлы сюда и создавай фикстуры в ../conftest.py

View File

@ -1,13 +0,0 @@
import re
from playwright.sync_api import Page, expect
def test_example(page: Page) -> None:
page.goto("https://vet.goznak.ru/auth")
page.get_by_role("textbox", name="Email").click()
page.get_by_role("textbox", name="Email").click()
page.get_by_role("textbox", name="Email").fill("gz_vetstation@vet.goznak.ru")
page.get_by_role("textbox", name="Введите пароль").click()
page.get_by_role("textbox", name="Введите пароль").fill("A6wrpzpD5T")
page.get_by_role("button", name="Войти в аккаунт").click()
expect(page.locator("#app")).to_match_aria_snapshot("- text: Ветеринарная станция \"ГОЗНАК\" - Демо\n- combobox: /\\d+\\.\\d+\\.\\d+ - \\d+\\.\\d+\\.\\d+/\n- button \"Создать отчет\":\n - img\n - text: \"\"\n- text: Статистика регистрации домашних животных\n- img\n- text: 0 всего особей Всего особей\n- paragraph: \"0\"\n- list:\n - listitem:\n - img\n - text: \"0\"\n - img\n - text: \"0\"\n- text: Из них с биометрией\n- paragraph: \"0\"\n- list:\n - listitem:\n - img\n - text: \"0\"\n - img\n - text: \"0\"\n- text: Динамика регистраций в ветстанции\n- img\n- text: Последние зарегистрированные особи\n- button \"Перейти к списку\"\n- table:\n - rowgroup:\n - row \"ID особи Дата № регистрации Тип маркировки № маркировки Кличка Тип владелеца\":\n - columnheader \"ID особи\"\n - columnheader \"Дата\"\n - columnheader \"№ регистрации\"\n - columnheader \"Тип маркировки\"\n - columnheader \"№ маркировки\"\n - columnheader \"Кличка\"\n - columnheader \"Тип владелеца\"\n - columnheader\n - rowgroup:\n - row /homeless \\d+ \\d+\\.\\d+\\.\\d+ \\d+ С \\d+ \\d+ \\d+ Нет информации о маркировке Без номера Кличко Юридическое лицо/:\n - cell /homeless \\d+/:\n - img \"homeless\"\n - img\n - img\n - text: \"\"\n - cell /\\d+\\.\\d+\\.\\d+/\n - cell /\\d+ С \\d+ \\d+ \\d+/\n - cell \"Нет информации о маркировке\"\n - cell \"Без номера\"\n - cell \"Кличко\"\n - cell \"Юридическое лицо\"\n - cell:\n - button:\n - img\n - row /homeless \\d+ \\d+\\.\\d+\\.\\d+ \\d+ К \\d+ \\d+ \\d+ Нет информации о маркировке Без номера Звезда Юридическое лицо/:\n - cell /homeless \\d+/:\n - img \"homeless\"\n - img\n - img\n - text: \"\"\n - cell /\\d+\\.\\d+\\.\\d+/\n - cell /\\d+ К \\d+ \\d+ \\d+/\n - cell \"Нет информации о маркировке\"\n - cell \"Без номера\"\n - cell \"Звезда\"\n - cell \"Юридическое лицо\"\n - cell:\n - button:\n - img\n - row /homeless \\d+ \\d+\\.\\d+\\.\\d+ \\d+ К \\d+ \\d+ \\d+ Клеймо К-\\d+ Симба Физическое лицо/:\n - cell /homeless \\d+/:\n - img \"homeless\"\n - img\n - img\n - text: \"\"\n - cell /\\d+\\.\\d+\\.\\d+/\n - cell /\\d+ К \\d+ \\d+ \\d+/\n - cell \"Клеймо\"\n - cell /К-\\d+/\n - cell \"Симба\"\n - cell \"Физическое лицо\"\n - cell:\n - button:\n - img\n - row /homeless \\d+ \\d+\\.\\d+\\.\\d+ \\d+ С \\d+ \\d+ \\d+ Микрочип Ч-\\d+ Жорик Юридическое лицо/:\n - cell /homeless \\d+/:\n - img \"homeless\"\n - img\n - img\n - text: \"\"\n - cell /\\d+\\.\\d+\\.\\d+/\n - cell /\\d+ С \\d+ \\d+ \\d+/\n - cell \"Микрочип\"\n - cell /Ч-\\d+/\n - cell \"Жорик\"\n - cell \"Юридическое лицо\"\n - cell:\n - button:\n - img\n - row /homeless \\d+ \\d+\\.\\d+\\.\\d+ \\d+ С \\d+ \\d+ \\d+ Нет информации о маркировке Без номера Дружок Физическое лицо/:\n - cell /homeless \\d+/:\n - img \"homeless\"\n - img\n - img\n - text: \"\"\n - cell /\\d+\\.\\d+\\.\\d+/\n - cell /\\d+ С \\d+ \\d+ \\d+/\n - cell \"Нет информации о маркировке\"\n - cell \"Без номера\"\n - cell \"Дружок\"\n - cell \"Физическое лицо\"\n - cell:\n - button:\n - img")

View File

@ -0,0 +1 @@
# BP01: Навигация по разделам ЛК ветеринарной станции

View File

@ -0,0 +1,85 @@
import os
# pyrefly: ignore [missing-import]
import pytest
# pyrefly: ignore [missing-import]
from playwright.sync_api import Page, expect
BASE_URL = os.getenv("BASE_URL", "https://vet.goznak.ru")
@pytest.mark.vetstation
@pytest.mark.usefixtures("browser_page")
class TestBP01Dashboard:
"""
BP01: Навигация по разделам личного кабинета ветеринарной станции
Проверяет что пользователь с ролью 'Ветеринарная станция' может:
- Открыть главную страницу (Статистика/Dashboard)
- Видеть заголовок своей организации
- Видеть блок статистики регистрации животных
- Видеть таблицу последних зарегистрированных особей
- Перейти в раздел Животные
- Перейти в раздел Владельцы животных
- Перейти в раздел Единый реестр
"""
@pytest.mark.dependency(name="VSBP01::s01")
def test_s01_open_dashboard(self, browser_page: Page, bp_state: dict):
"""Шаг 1: Открыть главную страницу (Статистика)"""
browser_page.goto(BASE_URL + "/dashboard")
expect(browser_page).to_have_url(BASE_URL + "/dashboard")
@pytest.mark.dependency(name="VSBP01::s02", depends=["VSBP01::s01"])
def test_s02_check_org_header(self, browser_page: Page, bp_state: dict):
"""Шаг 2: Проверить заголовок организации"""
expect(browser_page.locator("#app")).to_contain_text(
"Ветеринарная станция"
)
@pytest.mark.dependency(name="VSBP01::s03", depends=["VSBP01::s02"])
def test_s03_check_stats_block(self, browser_page: Page, bp_state: dict):
"""Шаг 3: Проверить блок статистики регистрации животных"""
expect(
browser_page.get_by_text("Статистика регистрации домашних животных")
).to_be_visible(timeout=10000)
expect(
browser_page.get_by_text("Всего особей").first
).to_be_visible(timeout=10000)
@pytest.mark.dependency(name="VSBP01::s04", depends=["VSBP01::s03"])
def test_s04_check_animals_table(self, browser_page: Page, bp_state: dict):
"""Шаг 4: Проверить таблицу последних зарегистрированных особей"""
expect(
browser_page.get_by_text("Последние зарегистрированные особи")
).to_be_visible(timeout=10000)
# Проверяем наличие колонок таблицы
expect(browser_page.get_by_role("columnheader", name="Кличка")).to_be_visible()
expect(browser_page.get_by_role("columnheader", name="Дата")).to_be_visible()
@pytest.mark.dependency(name="VSBP01::s05", depends=["VSBP01::s04"])
def test_s05_go_to_animals(self, browser_page: Page, bp_state: dict):
"""Шаг 5: Перейти в раздел Животные"""
browser_page.get_by_role("link", name="Животные").click()
expect(browser_page).to_have_url(BASE_URL + "/animals", timeout=10000)
expect(browser_page.locator("#app")).to_be_visible(timeout=10000)
@pytest.mark.dependency(name="VSBP01::s06", depends=["VSBP01::s05"])
def test_s06_go_to_owners(self, browser_page: Page, bp_state: dict):
"""Шаг 6: Перейти в раздел Владельцы животных"""
import re
browser_page.get_by_role("link", name="Владельцы животных").click()
# Страница открывается с query-параметром (?type=individual), поэтому regex
expect(browser_page).to_have_url(
re.compile(r".*/owners.*"), timeout=10000
)
expect(browser_page.locator("#app")).to_be_visible(timeout=10000)
@pytest.mark.dependency(name="VSBP01::s07", depends=["VSBP01::s06"])
def test_s07_go_to_registry(self, browser_page: Page, bp_state: dict):
"""Шаг 7: Перейти в раздел Единый реестр"""
browser_page.get_by_role("link", name="Единый реестр").click()
expect(browser_page).to_have_url(BASE_URL + "/registry", timeout=10000)
expect(browser_page.locator("#app")).to_be_visible(timeout=10000)

View File

@ -0,0 +1,47 @@
# pyrefly: ignore [missing-import]
import pytest
from pathlib import Path
# ---------------------------------------------------------------------------
# Пути к статическим артефактам роли (фото, документы, файлы)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def fixtures_dir() -> Path:
"""
Путь к папке со статическими артефактами роли vetstation.
Структура папки:
fixtures/
├── images/ ← фотографии животных, документы-сканы
└── docs/ ← PDF, сертификаты и прочие документы
"""
return Path(__file__).parent / "fixtures"
# ---------------------------------------------------------------------------
# Готовые фикстуры для конкретных артефактов
# Добавляй новые по мере появления новых файлов в папке fixtures/
# ---------------------------------------------------------------------------
# @pytest.fixture(scope="session")
# def animal_photo(fixtures_dir) -> Path:
# """Фото животного для теста регистрации."""
# return fixtures_dir / "images" / "animal_photo.jpg"
# ---------------------------------------------------------------------------
# Контейнер для передачи данных между разными БП одной роли (scope=session)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def shared_state() -> dict:
"""
Словарь для передачи данных между разными бизнес-процессами
в рамках одной тестовой сессии для роли vetstation.
Пример:
# В BP02 создаём животное → BP03 проверяет его в реестре
shared_state["animal_id"] = "96416"
"""
return {}

View File

@ -0,0 +1 @@
# PDF, сертификаты, ветпаспорта для роли vetstation

View File

@ -0,0 +1 @@
# Фотографии животных и документы-сканы для роли vetstation

View File

@ -1,41 +1,180 @@
import os
# pyrefly: ignore [missing-import]
import pytest
from playwright.sync_api import Page
# pyrefly: ignore [missing-import]
from dotenv import load_dotenv
# pyrefly: ignore [missing-import]
from playwright.sync_api import Page, Playwright
# Словарь с учетными данными для разных ролей
from tests.utils.tls import detect_tls_mode, launch_browser_for_tls, tls_info_key
# Загружаем переменные из .env
load_dotenv()
# Базовый URL портала — берётся из .env или дефолт
BASE_URL = os.getenv("BASE_URL", "https://vet.goznak.ru")
# Учётные данные для разных ролей — берутся из .env
CREDENTIALS = {
"vetdept": {"login": "gz_vetdept@vet.goznak.ru", "password": "y2pyBAn7zw"},
"vetstation": {"login": "gz_vetstation@vet.goznak.ru", "password": "A6wrpzpD5T"},
"prefecture": {"login": "gz_prefecture@vet.goznak.ru", "password": "sbT9shGsbe"},
"vetdept": {
"login": os.getenv("VETDEPT_LOGIN"),
"password": os.getenv("VETDEPT_PASSWORD"),
},
"vetstation": {
"login": os.getenv("VETSTATION_LOGIN"),
"password": os.getenv("VETSTATION_PASSWORD"),
},
"prefecture": {
"login": os.getenv("PREFECTURE_LOGIN"),
"password": os.getenv("PREFECTURE_PASSWORD"),
},
}
@pytest.fixture(scope="function")
def role_page(page: Page, request):
"""
Фикстура, которая определяет роль теста и автоматически авторизует пользователя.
"""
# 2. Выполняем логин
role = None
def _get_role(request) -> str:
"""Определяет роль теста по pytest-маркеру."""
for marker in request.node.iter_markers():
if marker.name in CREDENTIALS:
role = marker.name
break
# 3. Если маркер не найден, честно говорим об этом (вместо молчаливого NameError)
if role is None:
pytest.fail("ОШИБКА: У теста нет маркера роли (например, @pytest.mark.vetstation)!")
return marker.name
pytest.fail("ОШИБКА: У теста нет маркера роли (например, @pytest.mark.vetdept)!")
print(f"\n[АВТОЛОГИН] Выполняется вход под ролью: {role}")
page.goto("https://vet.goznak.ru")
def _login(page: Page, role: str) -> None:
"""Выполняет авторизацию на портале под указанной ролью."""
creds = CREDENTIALS[role]
if not creds["login"] or not creds["password"]:
pytest.fail(
f"ОШИБКА: Переменные окружения для роли '{role}' не заданы. "
f"Скопируй .env.example в .env и заполни данные."
)
print(f"\n[АВТОЛОГИН] Роль: {role} | URL: {BASE_URL}")
page.goto(f"{BASE_URL}/auth")
page.wait_for_load_state("networkidle")
page.get_by_role("textbox", name="Email").fill(creds["login"])
page.get_by_role("textbox", name="Введите пароль").fill(creds["password"])
page.get_by_role("button", name="Войти в аккаунт").click()
page.wait_for_selector("#app", timeout=10000)
yield page
# Ожидаем успешного входа: переход с /auth в личный кабинет (/dashboard и т.д.)
page.wait_for_url(lambda url: "/auth" not in url, timeout=15000)
page.wait_for_load_state("networkidle")
page.context.clear_cookies()
# ---------------------------------------------------------------------------
# Фикстура: браузер с авто-адаптацией под тип TLS (session scope)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def _auto_browser(playwright: Playwright, browser_type_launch_args: dict, pytestconfig):
"""
Сессионный браузер, адаптированный под тип TLS целевого портала.
Применяет аргументы запуска из pytest.ini и CLI (--headed, --slowmo, --tls-mode).
"""
# Получаем результат детекции из stash или вычисляем
tls_info = pytestconfig.stash.get(tls_info_key, None)
if tls_info is None:
cli_tls_mode = pytestconfig.getoption("--tls-mode", default="auto")
force_mode = None if cli_tls_mode == "auto" else cli_tls_mode
tls_info = detect_tls_mode(BASE_URL, force_mode=force_mode)
is_gost, tls_desc = tls_info
browser, browser_desc = launch_browser_for_tls(
playwright=playwright,
launch_args=browser_type_launch_args,
is_gost=is_gost,
base_url=BASE_URL,
)
print(f"\n[БРАУЗЕР] {browser_desc} | {tls_desc}")
yield browser
browser.close()
# ---------------------------------------------------------------------------
# Фикстура для бизнес-процессов (scope=class)
# Один браузер на весь класс — авторизация выполняется один раз.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="class")
def browser_page(_auto_browser, request, pytestconfig):
"""
Авторизованная страница с жизненным циклом на весь класс (бизнес-процесс).
Все шаги внутри класса работают в одной вкладке браузера.
Браузер выбирается автоматически:
- Стандартный TLS -> встроенный Chromium
- ГОСТ TLS -> Chromium GOST (exe или CDP)
"""
role = _get_role(request)
ignore_https = pytestconfig.getoption("--ignore-https-errors", default=True)
context = _auto_browser.new_context(
ignore_https_errors=ignore_https,
viewport={"width": 1920, "height": 1080},
)
page = context.new_page()
_login(page, role)
yield page
context.close()
# ---------------------------------------------------------------------------
# Контейнер для передачи данных между шагами одного БП (scope=class)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="class")
def bp_state() -> dict:
"""
Словарь для передачи данных между шагами одного бизнес-процесса.
Создаётся при старте класса, уничтожается после его завершения.
Пример:
def test_s01_create(self, browser_page, bp_state):
bp_state["claim_id"] = "12345"
def test_s02_verify(self, browser_page, bp_state):
assert bp_state["claim_id"] == "12345"
"""
return {}
# ---------------------------------------------------------------------------
# Устаревшая фикстура — оставлена для обратной совместимости
# ---------------------------------------------------------------------------
@pytest.fixture(scope="function")
def role_page(_auto_browser, request, pytestconfig):
"""
[DEPRECATED] Авторизованная страница с жизненным циклом на один тест.
Для новых тестов используй browser_page (scope=class).
"""
role = _get_role(request)
ignore_https = pytestconfig.getoption("--ignore-https-errors", default=True)
context = _auto_browser.new_context(
ignore_https_errors=ignore_https,
viewport={"width": 1920, "height": 1080},
)
page = context.new_page()
_login(page, role)
yield page
context.close()
# ---------------------------------------------------------------------------
# Хук для отображения читаемых имён шагов в pytest-html отчёте
# ---------------------------------------------------------------------------
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""
Заменяет технический путь к тесту в колонке Test отчёта pytest-html
на docstring метода (например, 'Шаг 1: Открыть главную страницу').
Если docstring отсутствует — оставляет оригинальный nodeid.
"""
outcome = yield
report = outcome.get_result()
# Берём docstring тестовой функции
doc = getattr(item.function, "__doc__", None)
if doc:
description = doc.strip().splitlines()[0] # только первая строка
report.nodeid = description

3
tests/testdata/__init__.py vendored Normal file
View File

@ -0,0 +1,3 @@
# Пакет тестовых данных
# Содержит генераторы случайных данных по правилам ФЛК
from .generators import * # noqa: F401, F403

198
tests/testdata/generators.py vendored Normal file
View File

@ -0,0 +1,198 @@
"""
Генераторы тестовых данных по правилам ФЛК (Форматно-Логический Контроль).
=============================================================================
КАК ДОБАВИТЬ НОВЫЙ ГЕНЕРАТОР
=============================================================================
1. Определи раздел (секцию) к которому относится данное поле.
Разделы обозначены комментариями: # === РАЗДЕЛ: ... ===
2. Если нужного раздела нет — добавь новый в конце файла по шаблону:
# =============================================================================
# === РАЗДЕЛ: Название раздела ===
# =============================================================================
3. Напиши функцию-генератор в нужном разделе:
def gen_<сущность>_<поле>(<параметры>) -> <тип>:
\"\"\"
Краткое описание что генерирует функция.
ФЛК:
- Правило 1 (например: только цифры)
- Правило 2 (например: длина ровно 15 символов)
- Правило 3 (например: начинается с '643')
Аргументы:
param1 — описание параметра и его диапазон
Пример вывода: '643094100123456'
\"\"\"
...
4. Если функция использует Faker — используй уже созданный экземпляр `fake`
(не создавай новые экземпляры в каждой функции).
5. Если данные статические (например, список имён) — помести их как константу
перед функцией с именем _DATA_<НАЗНАЧЕНИЕ>.
=============================================================================
ПРИМЕР ИСПОЛЬЗОВАНИЯ В ТЕСТЕ
=============================================================================
from tests.testdata.generators import gen_microchip, gen_animal_name
class TestBP02RegisterAnimal:
def test_s01_fill_form(self, browser_page, bp_state):
name = gen_animal_name()
microchip = gen_microchip()
browser_page.fill("#animal-name", name)
browser_page.fill("#microchip", microchip)
bp_state["animal_name"] = name # передаём следующему шагу
bp_state["microchip"] = microchip
=============================================================================
"""
import random
import string
from datetime import date, timedelta
# pyrefly: ignore [missing-import]
from faker import Faker
# Единственный экземпляр Faker на весь модуль — не создавай новые в функциях
fake = Faker("ru_RU")
# =============================================================================
# === РАЗДЕЛ: Животное ===
# =============================================================================
_DATA_ANIMAL_NAMES = [
"Мурка", "Барсик", "Шарик", "Рекс", "Симба",
"Дружок", "Звезда", "Кличко", "Жорик", "Буся",
"Тузик", "Маркиз", "Пуговка", "Снежок", "Рыжик",
]
def gen_animal_name() -> str:
"""
Кличка животного.
ФЛК:
- Только кириллица
- Первая буква заглавная
- От 2 до 20 символов
Пример вывода: 'Мурка'
"""
return random.choice(_DATA_ANIMAL_NAMES)
def gen_birth_date(min_years: int = 0, max_years: int = 20) -> str:
"""
Дата рождения животного.
ФЛК:
- Не позднее сегодняшнего дня
- Не ранее max_years лет назад
- Формат: ДД.ММ.ГГГГ
Аргументы:
min_years — минимальный возраст в годах (по умолчанию 0 = только что рождён)
max_years — максимальный возраст в годах (по умолчанию 20)
Пример вывода: '15.03.2021'
"""
min_days = min_years * 365
max_days = max_years * 365
delta = random.randint(min_days, max_days)
birth = date.today() - timedelta(days=delta)
return birth.strftime("%d.%m.%Y")
# =============================================================================
# === РАЗДЕЛ: Маркировка животного ===
# =============================================================================
def gen_microchip() -> str:
"""
Номер микрочипа.
ФЛК:
- Ровно 15 цифр
- Стандарт ISO 11784/85
- Первые 3 цифры — код страны (643 = Россия) [опционально]
Пример вывода: '643094100123456'
"""
return "".join(str(random.randint(0, 9)) for _ in range(15))
def gen_tattoo() -> str:
"""
Номер клейма (тату-маркировка).
ФЛК:
- 2 заглавные латинские буквы
- 4 цифры
- Итого 6 символов
Пример вывода: 'AB1234'
"""
letters = "".join(random.choices(string.ascii_uppercase, k=2))
digits = "".join(str(random.randint(0, 9)) for _ in range(4))
return letters + digits
# =============================================================================
# === РАЗДЕЛ: Владелец животного ===
# =============================================================================
def gen_owner_full_name() -> str:
"""
ФИО владельца.
ФЛК:
- Только кириллица
- Формат: Фамилия Имя Отчество
Пример вывода: 'Иванов Иван Иванович'
"""
return fake.name()
def gen_owner_phone() -> str:
"""
Мобильный телефон владельца.
ФЛК:
- Российский номер
- Начинается с +7
- Код оператора: 9XX (мобильные)
- Формат: +7(9XX)XXX-XX-XX
Пример вывода: '+7(916)543-21-09'
"""
code = random.randint(900, 999)
part1 = random.randint(100, 999)
part2 = random.randint(10, 99)
part3 = random.randint(10, 99)
return f"+7({code}){part1}-{part2}-{part3}"
def gen_owner_email() -> str:
"""
Email владельца.
ФЛК:
- Валидный формат email
- Латинские символы
Пример вывода: 'ivanov.ivan@example.com'
"""
return fake.email()

3
tests/utils/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .tls import detect_tls_mode, launch_browser_for_tls
__all__ = ["detect_tls_mode", "launch_browser_for_tls"]

82
tests/utils/test_tls.py Normal file
View File

@ -0,0 +1,82 @@
"""
Тесты модуля tests/utils/tls.py:
Проверка двухфазной детекции TLS, кэширования и автопоиска ГОСТ-браузера.
"""
import ssl
from unittest.mock import patch, MagicMock
import pytest
from tests.utils.tls import detect_tls_mode, find_gost_browser_executable, _probe_tls_socket
def test_detect_tls_mode_http():
is_gost, desc = detect_tls_mode("http://example.com")
assert is_gost is False
assert "HTTP" in desc
def test_detect_tls_mode_forced_gost():
is_gost, desc = detect_tls_mode("https://any-site.com", force_mode="gost")
assert is_gost is True
assert "ГОСТ" in desc
def test_detect_tls_mode_forced_standard():
is_gost, desc = detect_tls_mode("https://any-site.com", force_mode="standard")
assert is_gost is False
assert "Стандартный" in desc
def test_detect_tls_mode_real_goznak():
# Реальный вызов к тестовому порталу
is_gost, desc = detect_tls_mode("https://vet.goznak.ru")
assert is_gost is False
assert "TLS" in desc
def test_probe_tls_untrusted_standard_cert():
"""
Проверяет, что если сайт на стандартном TLS имеет недоверенный/самоподписанный сертификат,
он НЕ определяется ошибочно как ГОСТ.
"""
with patch("ssl.create_default_context") as mock_def_ctx, \
patch("ssl._create_unverified_context") as mock_unv_ctx, \
patch("socket.socket") as mock_socket:
# Первая попытка с проверкой сертификата падает с SSLCertVerificationError
mock_sock_inst = MagicMock()
mock_def_ctx.return_value.wrap_socket.side_effect = ssl.SSLCertVerificationError("certificate verify failed")
# Вторая попытка с CERT_NONE успешна
mock_unv_sock = MagicMock()
mock_unv_sock.version.return_value = "TLSv1.3"
mock_unv_sock.cipher.return_value = ("TLS_AES_256_GCM_SHA384", "TLSv1.3", 256)
mock_unv_ctx.return_value.wrap_socket.return_value.__enter__.return_value = mock_unv_sock
is_gost, desc = _probe_tls_socket("self-signed.example.com", 443)
assert is_gost is False
assert "недоверенный сертификат" in desc
def test_probe_tls_real_gost_cipher_error():
"""
Проверяет, что если handshake падает с ошибкой шифров (несовместимый протокол/шифр ГОСТ),
он определяется как ГОСТ TLS.
"""
with patch("ssl.create_default_context") as mock_def_ctx, \
patch("ssl._create_unverified_context") as mock_unv_ctx, \
patch("socket.socket") as mock_socket:
mock_def_ctx.return_value.wrap_socket.side_effect = ssl.SSLError(ssl.SSL_ERROR_SSL, "UNSUPPORTED_PROTOCOL")
mock_unv_ctx.return_value.wrap_socket.side_effect = ssl.SSLError(ssl.SSL_ERROR_SSL, "NO_CIPHERS_AVAILABLE")
is_gost, desc = _probe_tls_socket("gost.example.com", 443)
assert is_gost is True
assert "ГОСТ TLS" in desc
def test_find_gost_browser_executable():
"""Проверяет поиск ГОСТ-браузера."""
path = find_gost_browser_executable()
# На данной системе должен найти установленный браузер или вернуть None
if path:
assert ("chrome.exe" in path.lower()) or ("browser.exe" in path.lower())

237
tests/utils/tls.py Normal file
View File

@ -0,0 +1,237 @@
"""
Утилита для определения типа TLS (стандартный vs ГОСТ TLS)
и управления запуском соответствующего браузера.
"""
import functools
import os
import shutil
import socket
import ssl
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlparse
# pyrefly: ignore [missing-import]
import pytest
# pyrefly: ignore [missing-import]
from playwright.sync_api import Browser, Playwright
# Ключ для сохранения результатов детекции TLS в pytest stash
tls_info_key = pytest.StashKey[Tuple[bool, str]]()
def find_gost_browser_executable() -> Optional[str]:
"""
Выполняет автопоиск исполняемого файла Chromium-GOST или Яндекс.Браузера,
поддерживающего ГОСТ TLS через системный криптопровайдер (КриптоПро CSP).
Возвращает:
Абсолютный путь к найденному браузеру или None.
"""
# 1. Приоритет — путь, явно заданный в .env
custom_path = os.getenv("CHROMIUM_GOST_PATH", "").strip()
if custom_path and os.path.exists(custom_path):
return os.path.abspath(custom_path)
# 2. Список типовых путей установки в ОС Windows
local_app_data = os.environ.get("LOCALAPPDATA", "")
prog_files = os.environ.get("ProgramFiles", "")
prog_files_x86 = os.environ.get("ProgramFiles(x86)", "")
candidates = [
# Пути официального установщика Chromium-GOST (устанавливается в папку Chromium\Application)
os.path.join(local_app_data, "Chromium", "Application", "chrome.exe"),
os.path.join(local_app_data, "Chromium", "chrome.exe"),
os.path.join(local_app_data, "Programs", "Chromium", "Application", "chrome.exe"),
os.path.join(prog_files, "Chromium", "Application", "chrome.exe"),
os.path.join(prog_files, "Chromium", "chrome.exe"),
os.path.join(prog_files_x86, "Chromium", "Application", "chrome.exe"),
os.path.join(prog_files_x86, "Chromium", "chrome.exe"),
# Портативные пути и архивы Chromium-Gost
os.path.join(local_app_data, "Chromium-Gost", "chrome.exe"),
os.path.join(local_app_data, "Chromium-Gost", "Application", "chrome.exe"),
os.path.join(local_app_data, "Programs", "Chromium-Gost", "chrome.exe"),
os.path.join(local_app_data, "Programs", "Chromium-Gost", "Application", "chrome.exe"),
os.path.join(prog_files, "Chromium-Gost", "chrome.exe"),
os.path.join(prog_files, "Chromium-Gost", "Application", "chrome.exe"),
os.path.join(prog_files_x86, "Chromium-Gost", "chrome.exe"),
os.path.join(prog_files_x86, "Chromium-Gost", "Application", "chrome.exe"),
# Linux / macOS пути
"/opt/chromium-gost/chrome",
"/usr/bin/chromium-gost",
"/Applications/Chromium-Gost.app/Contents/MacOS/Chromium-Gost",
]
for path in candidates:
if path and os.path.exists(path):
return os.path.abspath(path)
# 3. Поиск в системном PATH
for name in ("chromium-gost", "chrome-gost", "chromium"):
found = shutil.which(name)
if found:
return os.path.abspath(found)
return None
def _probe_tls_socket(host: str, port: int) -> Tuple[bool, str]:
"""
Выполняет двухфазную проверку TLS сокета:
Фаза 1: стандартная проверка с валидацией цепочки сертификатов.
Фаза 2: если ошибка связана с недоверенным/тестовым сертификатом,
проверяется handshake без валидации сертификата (CERT_NONE).
Если он успешен — это стандартный TLS (не ГОСТ!).
Если же handshake не может договориться по ciphers/protocols — это ГОСТ TLS.
"""
# Фаза 1: Обычный контекст с валидацией
ctx = ssl.create_default_context()
try:
with ctx.wrap_socket(socket.socket(), server_hostname=host) as s:
s.settimeout(5)
s.connect((host, port))
cipher = s.cipher()
cipher_name = cipher[0] if cipher else "Unknown"
return False, f"Стандартный TLS ({s.version()}, {cipher_name})"
except ssl.SSLCertVerificationError as e:
# Сертификат недоверен / самоподписан, но шифрование может быть стандартным!
# Проверяем Фазу 2 с CERT_NONE
try:
unverified_ctx = ssl._create_unverified_context()
with unverified_ctx.wrap_socket(socket.socket(), server_hostname=host) as s:
s.settimeout(5)
s.connect((host, port))
cipher = s.cipher()
cipher_name = cipher[0] if cipher else "Unknown"
return False, f"Стандартный TLS (недоверенный сертификат: {s.version()}, {cipher_name})"
except ssl.SSLError:
pass
return True, f"ГОСТ TLS (ошибка верификации и шифра: {e})"
except ssl.SSLError as e:
reason = getattr(e, "reason", str(e))
# Пробуем без проверки сертификата на случай, если причина в самоподписанном сертификате
try:
unverified_ctx = ssl._create_unverified_context()
with unverified_ctx.wrap_socket(socket.socket(), server_hostname=host) as s:
s.settimeout(5)
s.connect((host, port))
cipher = s.cipher()
cipher_name = cipher[0] if cipher else "Unknown"
return False, f"Стандартный TLS (без валидации: {s.version()}, {cipher_name})"
except Exception:
# Стандартный OpenSSL не понимает ГОСТ ciphers / протокол
return True, f"ГОСТ TLS (OpenSSL handshake: {reason})"
except Exception as e:
# Эвристика: если сервис paws.* недоступен по таймауту/сети, но является целевым ГОСТ-контуром
if "paws" in host.lower():
return True, f"ГОСТ TLS (домен {host}, fallback: {e})"
return False, f"Стандартный TLS (fallback: {e})"
@functools.lru_cache(maxsize=32)
def detect_tls_mode(url: str, force_mode: Optional[str] = None) -> Tuple[bool, str]:
"""
Определяет тип TLS соединения для целевого URL с кэшированием результатов.
Возвращает:
(is_gost: bool, description: str)
Приоритет:
1. Аргумент force_mode (например, переданный из CLI pytest --tls-mode)
2. Переменная USE_GOST или TLS_MODE в .env
3. Двухфазная проверка через сокет (handshake):
- Успешное подключение стандартным OpenSSL -> Стандартный TLS (False)
- Ошибка шифрования (UNSUPPORTED_PROTOCOL, NO_CIPHERS_AVAILABLE и т.д.) -> ГОСТ TLS (True)
- Домен содержит "paws" при таймауте -> ГОСТ TLS (True)
"""
# 1. Приоритет — явный force_mode
mode = (force_mode or os.getenv("TLS_MODE") or os.getenv("USE_GOST") or "").strip().lower()
if mode in ("gost", "true", "1", "yes", "on"):
return True, "Принудительно (ГОСТ TLS)"
if mode in ("standard", "regular", "false", "0", "no", "off"):
return False, "Принудительно (Стандартный TLS)"
# 2. Проверка протокола
parsed = urlparse(url)
if parsed.scheme == "http":
return False, "HTTP (без TLS)"
host = parsed.hostname or url
port = parsed.port or 443
# 3. Проверка сокета
return _probe_tls_socket(host, port)
def launch_browser_for_tls(
playwright: Playwright,
launch_args: Dict[str, Any],
is_gost: bool,
base_url: str
) -> Tuple[Browser, str]:
"""
Запускает подходящий браузер в зависимости от типа TLS.
Возвращает:
(browser_instance, description_string)
Логика:
- Стандартный TLS: запускает встроенный Chromium Playwright с аргументами launch_args.
- ГОСТ TLS:
1. Ищет путь к Chromium-GOST / Яндекс.Браузеру через find_gost_browser_executable().
2. Если найден — запускает с заданными launch_args.
3. Если не найден — пробует подключиться к запущенному браузеру по CDP (CHROMIUM_GOST_CDP_PORT).
4. Если оба варианта недоступны — выдаёт информативную ошибку с подробными шагами решения.
"""
cdp_port = os.getenv("CHROMIUM_GOST_CDP_PORT", "9222").strip()
if not is_gost:
# Стандартный TLS
browser = playwright.chromium.launch(**launch_args)
return browser, "Chromium (Playwright)"
# ГОСТ TLS: Вариант 1 — автопоиск и запуск по executable_path
gost_path = find_gost_browser_executable()
if gost_path and os.path.exists(gost_path):
try:
browser = playwright.chromium.launch(
executable_path=gost_path,
**launch_args
)
browser_name = "Яндекс.Браузер" if "yandex" in gost_path.lower() else "Chromium GOST"
return browser, f"{browser_name} ({gost_path})"
except Exception as e:
err_msg = str(e).encode("ascii", errors="replace").decode("ascii")
print(f"\n[GOST] Предупреждение: ошибка прямого запуска ({gost_path}): {err_msg}")
print("[GOST] Пробуем подключение по CDP...")
# ГОСТ TLS: Вариант 2 — подключение по CDP
cdp_url = f"http://localhost:{cdp_port}"
try:
connect_args: Dict[str, Any] = {"timeout": 4000}
if "slow_mo" in launch_args:
connect_args["slow_mo"] = launch_args["slow_mo"]
browser = playwright.chromium.connect_over_cdp(cdp_url, **connect_args)
return browser, f"Chromium GOST (CDP порт {cdp_port})"
except Exception:
pass
# Ничего не подошло — информативное сообщение об ошибке
pytest.fail(
f"\n{'=' * 70}\n"
f"ОШИБКА: Обнаружен ГОСТ TLS для адреса: {base_url}\n"
f"Стандартный встроенный Chromium не поддерживает криптоалгоритмы ГОСТ.\n\n"
f"Для запуска тестов выберите один из вариантов:\n\n"
f"Вариант 1 (Автозапуск — рекомендуемый):\n"
f" 1. Скачайте Chromium GOST:\n"
f" https://github.com/deemru/chromium-gost/releases\n"
f" 2. Распакуйте/установите его (он будет найден автоматически), либо укажите в .env:\n"
f" CHROMIUM_GOST_PATH=C:\\Path\\To\\chromium-gost\\chrome.exe\n"
f" * Примечание: также поддерживается установленный Яндекс.Браузер с КриптоПро CSP.\n\n"
f"Вариант 2 (Подключение по CDP):\n"
f" 1. Запустите Chromium GOST вручную с флагом отладки:\n"
f" chrome.exe --remote-debugging-port={cdp_port} --no-first-run\n"
f" 2. Запустите pytest повторно\n"
f"{'=' * 70}"
)