This commit is contained in:
2026-10-11 03:05:52 +07:00
parent 7fe5525a01
commit 5973eaf072
5 changed files with 684 additions and 4 deletions
+8 -3
View File
@@ -27,13 +27,13 @@
### ED-029 — Автономная площадка и панель организатора ### ED-029 — Автономная площадка и панель организатора
- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–6 реализованы и проверены, следующий — интерфейс передачи пакета. - Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–7 завершены; автоматическая часть этапа 8 завершена, ожидает аппаратной приёмки.
- Репозитории: корневой, `evening_detective_server`, `evening_detective_frontend`. - Репозитории: корневой, `evening_detective_server`, `evening_detective_frontend`.
- Цель: реализовать принятый пользователем план из 15 последовательных этапов: автономное окружение; локальные организаторы; прежние адреса через Cudy; пакет игры; экспорт со статусом офлайн-режима; локальный импорт; интерфейс передачи; полный билетный и игровой flow; пакет результатов; возврат онлайн; интерфейс возврата; устройства; панель состояния; сбор данных Cudy; Wi-Fi и испытание площадки. - Цель: реализовать принятый пользователем план из 15 последовательных этапов: автономное окружение; локальные организаторы; прежние адреса через Cudy; пакет игры; экспорт со статусом офлайн-режима; локальный импорт; интерфейс передачи; полный билетный и игровой flow; пакет результатов; возврат онлайн; интерфейс возврата; устройства; панель состояния; сбор данных Cudy; Wi-Fi и испытание площадки.
- Критерии: проверяемый результат каждого этапа; цикл «онлайн → ноутбук → проведение → онлайн» через интерфейс с сохранением QR, билетов и связей; повторные импорты без дублей; мониторинг устройств и Wi-Fi; полный прогон без WAN, 100 клиентов и проверка покрытия. - Критерии: проверяемый результат каждого этапа; цикл «онлайн → ноутбук → проведение → онлайн» через интерфейс с сохранением QR, билетов и связей; повторные импорты без дублей; мониторинг устройств и Wi-Fi; полный прогон без WAN, 100 клиентов и проверка покрытия.
- Ограничения: только тестовые игры до завершения возврата; существующие правила игры и формат `offline` сохраняются; без несогласованных новых зависимостей и отклонений от стандартов; без персональных списков игроков, CPU/памяти Cudy и автоматического переключения серверов; настройки роутера менять только по явному запросу пользователя; секреты и пакеты вне Git; commit/push выполняет пользователь. - Ограничения: только тестовые игры до завершения возврата; существующие правила игры и формат `offline` сохраняются; без несогласованных новых зависимостей и отклонений от стандартов; без персональных списков игроков, CPU/памяти Cudy и автоматического переключения серверов; настройки роутера менять только по явному запросу пользователя; секреты и пакеты вне Git; commit/push выполняет пользователь.
- Проверка: Go-стандарт и `go test ./...`; PostgreSQL-интеграционные тесты для атомарности; frontend-стандарт, type-check, lint, lint:css и build; штатная генерация protobuf; ручные контрольные точки после этапов 3, 8, 11, 15. - Проверка: Go-стандарт и `go test ./...`; PostgreSQL-интеграционные тесты для атомарности; frontend-стандарт, type-check, lint, lint:css и build; штатная генерация protobuf; ручные контрольные точки после этапов 3, 8, 11, 15.
- Прогресс: этапы 1–2 реализованы и проверены в локальном Docker-окружении. Добавлены отдельный Compose, подготовка образов заранее, постоянные тома, миграции перед стартом, healthchecks и прокси. Явный `APP_MODE=venue` отключает SMTP, фоновые письма и внешний webhook; почтовые операции аккаунта возвращают безопасную ошибку до изменения данных. Локальные аккаунты создаются штатным `dev-user.sql`, роли и правила автоматического подтверждения команд сохраняются. Этап 3 принят пользователем после проверки без WAN; формат и код пакета игры (этап 4) реализованы и проверены. Экспорт со статусом офлайн-режима (этап 5) и локальный импорт (этап 6) реализованы и проверены. Этапы 7–15 ещё не завершены. - Прогресс: этапы 1–2 реализованы и проверены в локальном Docker-окружении. Добавлены отдельный Compose, подготовка образов заранее, постоянные тома, миграции перед стартом, healthchecks и прокси. Явный `APP_MODE=venue` отключает SMTP, фоновые письма и внешний webhook; почтовые операции аккаунта возвращают безопасную ошибку до изменения данных. Локальные аккаунты создаются штатным `dev-user.sql`, роли и правила автоматического подтверждения команд сохраняются. Этап 3 принят пользователем после проверки без WAN; формат и код пакета игры (этап 4) реализованы и проверены. Экспорт со статусом офлайн-режима (этап 5) и локальный импорт (этап 6) реализованы и проверены. Этап 7 реализован и проверен; автоматическая часть этапа 8 проверена на изолированном стенде; аппаратная приёмка ожидается, этапы 9–15 ещё не завершены.
- Дополнение по запросу пользователя: структура `venue` приведена к `prod` (`backend/`, `frontend/dist/`, `migrations/`, `Dockerfile.backend`, `docker-compose.yml`, `deploy.sh`); имена основных Compose-сервисов совпадают. Общая генерация `make build` / `make prod-build` готовит артефакты обеих папок; frontend собирается отдельно с настройками своего окружения. Текущие файлы `prod` при этой работе не изменяются; сборка проверяется с production-выводом в `/tmp`. - Дополнение по запросу пользователя: структура `venue` приведена к `prod` (`backend/`, `frontend/dist/`, `migrations/`, `Dockerfile.backend`, `docker-compose.yml`, `deploy.sh`); имена основных Compose-сервисов совпадают. Общая генерация `make build` / `make prod-build` готовит артефакты обеих папок; frontend собирается отдельно с настройками своего окружения. Текущие файлы `prod` при этой работе не изменяются; сборка проверяется с production-выводом в `/tmp`.
- Упрощение запуска: отдельные `init.sh` и `prepare.sh` удалены, всё собрано в `deploy.sh`. Единственная команда запуска — `make venue`: создаёт `.env` только при его отсутствии, при необходимости готовит зависимости/образы и автоматически создаёт организатора. Повторный готовый запуск не собирает и не скачивает образы. `make venue-update` явно пересобирает окружение с интернетом. Аккаунт организатора синхронизируется со значениями `.env` при каждом запуске. Первый запуск, повторный запуск без интернета и обновление проверяются `python3 tests/venue/deploy.py` с подставными командами; реальный готовый запуск проверен через Docker и API-smoke. - Упрощение запуска: отдельные `init.sh` и `prepare.sh` удалены, всё собрано в `deploy.sh`. Единственная команда запуска — `make venue`: создаёт `.env` только при его отсутствии, при необходимости готовит зависимости/образы и автоматически создаёт организатора. Повторный готовый запуск не собирает и не скачивает образы. `make venue-update` явно пересобирает окружение с интернетом. Аккаунт организатора синхронизируется со значениями `.env` при каждом запуске. Первый запуск, повторный запуск без интернета и обновление проверяются `python3 tests/venue/deploy.py` с подставными командами; реальный готовый запуск проверен через Docker и API-smoke.
- Настройки площадки: по запросу пользователя необязательный `venue/.env.example` удалён; настройки создаются автоматически через `make venue`. Убраны ссылка и проверка примера; `python3 tests/venue/deploy.py` прошёл. Существующая `.env` не изменялась. - Настройки площадки: по запросу пользователя необязательный `venue/.env.example` удалён; настройки создаются автоматически через `make venue`. Убраны ссылка и проверка примера; `python3 tests/venue/deploy.py` прошёл. Существующая `.env` не изменялась.
@@ -63,7 +63,12 @@
- Проверка этапа 5: ранее прошли реальный тестовый RustFS-цикл с перезапуском, проверкой ZIP кодеком, приватностью объекта, потоковой выдачей/обрывом и повторным скачиванием одинаковых байтов. После упрощения прошёл полный `go test ./...` с PostgreSQL: снимок, редактирование в `preparing`/`ready`, конкурентный запуск, аренда, устаревшая публикация, ошибка/повторный запуск и завершение/очистка после удаления игры. Frontend `type-check`, `lint`, `lint:css` и Vite build в `/tmp` прошли; `gofmt` и diff проверены. Проверка интерфейса в браузере и повторный реальный RustFS-цикл в этом уточнении не выполнялись. API описан в backend `docs/GAME_PACKAGE.md`. Production-развёртывание, commit и push не выполняются. - Проверка этапа 5: ранее прошли реальный тестовый RustFS-цикл с перезапуском, проверкой ZIP кодеком, приватностью объекта, потоковой выдачей/обрывом и повторным скачиванием одинаковых байтов. После упрощения прошёл полный `go test ./...` с PostgreSQL: снимок, редактирование в `preparing`/`ready`, конкурентный запуск, аренда, устаревшая публикация, ошибка/повторный запуск и завершение/очистка после удаления игры. Frontend `type-check`, `lint`, `lint:css` и Vite build в `/tmp` прошли; `gofmt` и diff проверены. Проверка интерфейса в браузере и повторный реальный RustFS-цикл в этом уточнении не выполнялись. API описан в backend `docs/GAME_PACKAGE.md`. Production-развёртывание, commit и push не выполняются.
- Результат этапа 6: добавлен потоковый `POST /api/games/import` (сырой ZIP, JWT, роли admin/developer/organizer, только venue) и соответствующий protobuf-контракт. Кодек проверяет пакет до записи; игра, команды, билеты, сценарий, плейлисты, треки, план экранов, карта и метаданные публикуются одной транзакцией после загрузки медиа в локальный RustFS. ID игры/команд/билетов, пароли QR, хеши и состояния билетов сохранены. Сценарий/плейлисты/треки получают отдельные локальные ID; сценарий принадлежит импортирующему пользователю, исходные пользовательские ссылки не связываются с локальными аккаунтами. Исходный снимок и соответствия ID сохраняются в закрытом реестре импорта. Занятые ID/хеши и другой пакет той же игры отклоняются; повтор того же ZIP ничего не меняет, в том числе после локального редактирования или удаления игры. Реестр, heartbeat 30 секунд, аренда 2 минуты и timeout 30 минут защищают публикацию; очистка ошибок повторяется после перезапуска и не удаляет медиа при неопределённом ответе commit. Для маршрута импорта venue-прокси настроены лимит 1 ГиБ и потоковая передача; production-конфигурация не менялась. - Результат этапа 6: добавлен потоковый `POST /api/games/import` (сырой ZIP, JWT, роли admin/developer/organizer, только venue) и соответствующий protobuf-контракт. Кодек проверяет пакет до записи; игра, команды, билеты, сценарий, плейлисты, треки, план экранов, карта и метаданные публикуются одной транзакцией после загрузки медиа в локальный RustFS. ID игры/команд/билетов, пароли QR, хеши и состояния билетов сохранены. Сценарий/плейлисты/треки получают отдельные локальные ID; сценарий принадлежит импортирующему пользователю, исходные пользовательские ссылки не связываются с локальными аккаунтами. Исходный снимок и соответствия ID сохраняются в закрытом реестре импорта. Занятые ID/хеши и другой пакет той же игры отклоняются; повтор того же ZIP ничего не меняет, в том числе после локального редактирования или удаления игры. Реестр, heartbeat 30 секунд, аренда 2 минуты и timeout 30 минут защищают публикацию; очистка ошибок повторяется после перезапуска и не удаляет медиа при неопределённом ответе commit. Для маршрута импорта venue-прокси настроены лимит 1 ГиБ и потоковая передача; production-конфигурация не менялась.
- Проверки этапа 6: штатные `make generate` и `npm run proto:gen`; полный обычный `go test ./...`; PostgreSQL-тесты импорта, экспорта и API; `go test -race` для imports repo/service и app; frontend type-check, lint, lint:css и Vite build в `/tmp`; `python3 tests/venue/deploy.py`, `python3 tests/venue/https.py --docker` и diff-проверки прошли. Тесты покрывают связи исходных ID, локальные копии, QR/билеты, пустые наборы данных, последовательности, повтор/конкурентность, конфликты до/после загрузки, изменённые байты под тем же package ID, rollback, отмену, аренду/timeout, ошибки очистки и неопределённый commit. Реальный HTTP/RustFS smoke прошёл в отдельной internal Docker-сети без WAN, с отдельными PostgreSQL/RustFS, чтением медиа, перезапуском хранилища и повторным импортом; тестовые контейнеры и данные удалены. Nginx проверен на обоих HTTPS-доменах, включая POST импорта. Ручное ревью изменений по Go/frontend-стандартам выполнено. Пакет на 1 ГиБ, реальные телефоны и обновление работающей площадки не проверялись; новая миграция `20261011120000_create_game_imports.sql` требуется при обновлении. Commit/push не выполнялись. - Проверки этапа 6: штатные `make generate` и `npm run proto:gen`; полный обычный `go test ./...`; PostgreSQL-тесты импорта, экспорта и API; `go test -race` для imports repo/service и app; frontend type-check, lint, lint:css и Vite build в `/tmp`; `python3 tests/venue/deploy.py`, `python3 tests/venue/https.py --docker` и diff-проверки прошли. Тесты покрывают связи исходных ID, локальные копии, QR/билеты, пустые наборы данных, последовательности, повтор/конкурентность, конфликты до/после загрузки, изменённые байты под тем же package ID, rollback, отмену, аренду/timeout, ошибки очистки и неопределённый commit. Реальный HTTP/RustFS smoke прошёл в отдельной internal Docker-сети без WAN, с отдельными PostgreSQL/RustFS, чтением медиа, перезапуском хранилища и повторным импортом; тестовые контейнеры и данные удалены. Nginx проверен на обоих HTTPS-доменах, включая POST импорта. Ручное ревью изменений по Go/frontend-стандартам выполнено. Пакет на 1 ГиБ, реальные телефоны и обновление работающей площадки не проверялись; новая миграция `20261011120000_create_game_imports.sql` требуется при обновлении. Commit/push не выполнялись.
- Следующий шаг: этап 7 — интерфейс запуска экспорта, скачивания и загрузки пакета на площадку. Полный билетный и игровой flow — этап 8; возврат результатов — последующие этапы. До реализации возврата использовать только тестовые игры. Общая задача не завершена и не переносится в архив. - Результат этапа 7: в настройках игры на подтверждённом prod доступны подготовка и скачивание пакета, состояния из `game.export` восстанавливаются через существующий polling; ошибка допускает повтор подготовки, готовый ZIP — повтор скачивания. Добавлены пояснения о снимке и отсутствии подтверждения импорта. На venue в списке игр добавлена Naive UI-модалка импорта одного непустого ZIP до 1 ГиБ с именем/размером; загрузка блокирует повтор, смену файла, Escape и закрытие. После импорта обновляется список, показаны обычный/повторный результат и открытие с проверкой доступности игры; удалённая игра не восстанавливается. Действия доступны admin/organizer/developer, при неизвестной среде скрыты. Бинарный клиент сохраняет JWT-refresh, отправляет исходный File с `application/zip`, использует generated тип импорта, поддерживает обе формы ошибок и имя из Content-Disposition; object URL освобождается. Backend/API, зависимости и маршруты не менялись.
- Проверки этапа 7: frontend type-check, lint, lint:css, Vite-сборка в `/tmp` и diff-проверка прошли; ручное ревью по frontend-стандарту выполнено. Все 17 тестов `node src/stores/__tests__/auth.test.ts` прошли, включая ZIP-body/JWT-refresh, результат импорта, оба формата серверных ошибок, сетевую ошибку и UTF-8-имя скачивания. Firefox с подставным API на 1440/390 px проверил export polling, восстановление подготовки после обновления, ошибку/повтор и скачивание; импорт, повтор, удалённую игру, конфликт и сетевую ошибку; пустой файл, расширение, лимит; блокировки отправки/выбора/закрытия, Escape, клавиатурный focus в модалке, роли и неизвестную среду. Реальный перенос между серверами, телефоны и Blob-пакет на 1 ГиБ в этом этапе не проверялись; развёртывание не выполнялось.
- Результат автоматической части этапа 8: добавлены повторяемые `tests/venue/flow.py` и `flow_browser.py`, команды и ручной контрольный список в README проверок. Актуальный код собирается в `/tmp`; стенд запускает два настоящих backend prod/venue с отдельными PostgreSQL/RustFS в Docker internal-сети без WAN, SMTP направлен в локальный sink. API проверяет оба формата, экспорт/импорт ZIP, исходные ссылки/ID/пароли команд и билетов, статусы участия, QR/PDF, обычный/повторный/конкурентный приём билетов, отказ без авторизации и неверные ссылки, локальные команды/билеты, старт/ходы/выдачу материалов/паузу/продолжение/завершение, ответы и баллы, результаты, перезапуск всех venue-сервисов и повторный импорт без потери прогресса. Проверена независимость источника и площадки; ограничения формата `offline` сохранены. После проверки удаляются только ресурсы стенда; ошибка очистки одного ресурса не останавливает попытки очистить остальные.
- Исправления этапа 8: общий gameplay-сервис проверяет `run` до записи хода и подтверждения участия, одинаково для API/MCP; безопасная ошибка отображается как FailedPrecondition. Кнопка хода блокируется вне `run` и при отправке; отказ показывается пользователю и сохраняет код. Кнопкам lifecycle добавлены доступные имена. Конкурентный приём билета теперь перечитывает текущее состояние и сообщает «билет уже использован», а не ошибочно «игра уже завершена». PostgreSQL-тест конкурентности синхронизирует запросы блокировкой строки: до исправления воспроизводил ошибку, после исправления проходит.
- Проверки этапа 8: `python3 tests/venue/flow.py --browser` прошёл с реальными API/хранилищем и отдельным браузерным стендом. Firefox проверил полный UI-flow на 1440 px (`online`) и 390 px (`offline`), реальное скачивание/загрузку ZIP, билеты, ходы и polling, паузу с введённым кодом, двойную отправку, отказ с сохранением ввода, выдачу материала, оценивание/результаты, завершённый экран игрока, неверный пароль и клавиатурный focus в модалке. Внутри стенда прошли PostgreSQL-регрессии gameplay/lifecycle, tickets, MCP и app; отдельно прошёл полный `go test ./...`. Frontend type-check, lint, lint:css и сборка в `/tmp` прошли; синтаксис Python, `gofmt`, diff-проверки, ручное ревью по Go/frontend-стандартам и независимое ревью затронутого flow выполнены. API/protobuf, миграции и зависимости не менялись. Работающие окружения и Cudy не изменялись; существующие пользовательские изменения бинарника и `prod` сохранены.
- Следующий шаг: ручная аппаратная приёмка этапа 8 по `tests/venue/README.md` — прежние ссылки, билеты/QR, игровой flow на Android/iPhone с отключёнными WAN и мобильными данными. После подтверждения — этап 9, пакет результатов. Обновление реальной площадки, камера/реальные телефоны, нагрузка на 100 клиентов, покрытие Wi-Fi, ZIP на 1 ГиБ и возврат результатов этим прогоном не проверялись. До реализации возврата использовать только тестовые игры. Общая задача остаётся `IN_PROGRESS` и не переносится в архив.
### ED-024 — Общая дизайн-система фронтенда ### ED-024 — Общая дизайн-система фронтенда
+1 -1
Submodule prod updated: 5ea5af1fca...46a9864c8c
+58
View File
@@ -27,3 +27,61 @@ python3 tests/venue/https.py --docker
python3 tests/venue/dns.py --docker python3 tests/venue/dns.py --docker
python3 tests/venue/dns.py --server 192.168.10.155 python3 tests/venue/dns.py --server 192.168.10.155
``` ```
## Полный flow ED-029, этап 8
```sh
python3 tests/venue/flow.py
python3 tests/venue/flow.py --browser
```
`flow.py` собирает актуальный Go/Vue-код во временном каталоге, поднимает
одноразовые prod/venue-серверы с отдельными PostgreSQL и RustFS в Docker-сети
`--internal`, затем удаляет только свои контейнеры, тома и сеть. Работающие
окружения, файлы `prod`/`venue`, их настройки и Cudy не меняются. Требуются
установленные Go, Node/npm, frontend-зависимости и доступ к Docker; образы
`postgres:15`, `rustfs/rustfs:latest` и `alpine:3.22` должны быть доступны локально
(скрипт использует `--pull=never`). SMTP источника направлен в локальный sink,
который закрывает соединение; успешная отправка писем не проверяется.
Проверяются оба формата (`offline` и `online`): экспорт и импорт настоящего ZIP,
исходные ссылки команд/билетов, статусы участия, QR/PDF, приём билетов и его
конкурентность, новые локальные команды/билеты, ходы и материалы, запрет ходов
до старта/на паузе/после завершения, перезапуск всех venue-сервисов, ответы,
оценивание, результаты и повторный импорт без потери прогресса. Дополнительно
запускаются PostgreSQL-регрессии gameplay/lifecycle, tickets, MCP и app. Выдача
токенов писем имитируется только в тестовой БД; решения капитанов выполняются
через штатный API. Возврат результатов на источник не выполняется.
`--browser` требует Firefox с Marionette и запускает отдельный чистый стенд.
Реальные интерфейсы проходят экспорт, скачивание, загрузку ZIP, проверку билета,
ходы, выдачу материала, оценивание и завершение на 1440 px (`online`) и 390 px
(`offline`). Проверяются клавиатурный focus, отказ на ход с сохранением ввода,
блокировка повторной отправки и кнопки на паузе при уже введённом коде.
Локальный HTTP-прокси переключает один публичный адрес между двумя реальными
backend, имитируя смену DNS; API-ответы не подменяются. Тестовые ссылки, ZIP,
профиль Firefox и сборки остаются во временном каталоге до конца прогона.
### Ручная контрольная точка этапа 8
Автоматический прогон не заменяет проверку телефонов и камеры. После отдельного
обновления реальной площадки актуальными артефактами, только на тестовой игре:
1. На источнике подготовить пакет с заранее выданными билетами и QR команд;
скачать и импортировать его на venue через интерфейс.
2. Отключить WAN и мобильные данные. На Android/iPhone открыть прежние ссылки
билета и команды через Wi-Fi площадки, проверить доверенный HTTPS.
3. Проверить билет у организатора и принять его; повтор должен показывать
использованный билет. Проверить QR камерой там, где браузер поддерживает
сканирование; при отсутствии поддержки проверить ручную вставку ссылки.
4. Начать игру, выполнить ход, выдать материал, поставить на паузу с введённым
следующим кодом: ход должен блокироваться. Продолжить и выполнить этот ход.
5. Проверить правила формата: в `offline` материалы выдаёт организатор и нет
цифровых ответов; в `online` игрок сохраняет ответ. Завершить игру, выставить
баллы и открыть результаты; игрок должен увидеть сообщение о завершении.
6. Повторить импорт исходного ZIP: билеты и локальный прогресс должны сохраниться.
Сообщить результат этой контрольной точки для приёмки этапа 8.
Нагрузка на 100 клиентов, покрытие Wi-Fi, ZIP на 1 ГиБ и возврат результатов
относятся к отдельным проверкам/этапам. До готовности возврата использовать
только тестовые игры.
+407
View File
@@ -0,0 +1,407 @@
"""Real prod -> ZIP -> venue lifecycle in disposable Docker resources, without WAN."""
import argparse
import base64
from concurrent.futures import ThreadPoolExecutor
import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import hashlib
import mimetypes
from pathlib import Path
import secrets
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[2]
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
MATERIAL = b'%PDF-1.4\n% ED-029 stage 8 test clue\n%%EOF\n'
def run(*args, input=None, cwd=None):
result = subprocess.run(args, input=input, cwd=cwd, text=True, capture_output=True, timeout=180)
if result.returncode:
raise RuntimeError(f'{args[0]} {args[1]} failed: {result.stderr[-2000:]}')
return result.stdout.strip()
def wait_for(check, description, seconds=60):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
try:
if check():
return
except (OSError, urllib.error.URLError, RuntimeError):
pass
time.sleep(.2)
raise AssertionError('Timed out: ' + description)
class API:
def __init__(self, base):
self.base = base
self.token = ''
def request(self, path, body=None, method=None, raw=False, expected=200):
headers = {}
if self.token:
headers['Authorization'] = 'Bearer ' + self.token
if isinstance(body, bytes):
headers['Content-Type'] = 'application/zip'
data = body
else:
headers['Content-Type'] = 'application/json'
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(self.base + path, data=data, headers=headers, method=method)
try:
response = OPENER.open(request, timeout=30)
except urllib.error.HTTPError as error:
response = error
with response:
payload = response.read()
assert response.status in (expected if isinstance(expected, tuple) else (expected,)), f'{method or request.get_method()} API: HTTP {response.status}, expected {expected}'
if raw:
return payload
result = json.loads(payload)
if response.status != 200:
assert result.get('code') or result.get('error'), 'Missing API error'
return result
def game(self, id):
return self.request(f'/api/games/{id}')['game']
def story(self, team, expected=200):
query = urllib.parse.urlencode({'password': team['password']})
return self.request(f'/api/teams/{team["ID"]}/story?{query}', expected=expected)
def move(self, team, code, expected=200):
return self.request(f'/api/teams/{team["ID"]}/actions', {'password': team['password'], 'code': code}, expected=expected)
class Stand:
def __init__(self, directory):
self.directory = Path(directory)
self.name = 'ed029-flow-' + uuid.uuid4().hex[:10]
self.containers = []
self.network_created = False
self.current = 'prod'
self.delay_actions = 0
self.apis = {}
stand = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
self.handle_request()
def do_POST(self):
self.handle_request()
def do_PUT(self):
self.handle_request()
def do_DELETE(self):
self.handle_request()
def handle_request(self):
if self.path.startswith('/api/'):
if self.command == 'POST' and self.path.endswith('/actions'):
time.sleep(stand.delay_actions)
headers = {key: value for key, value in self.headers.items() if key.lower() not in ('host', 'connection', 'content-length')}
length = int(self.headers.get('Content-Length', 0))
request = urllib.request.Request(stand.apis[stand.current].base + self.path,
data=self.rfile.read(length) if length else None, headers=headers, method=self.command)
try:
response = OPENER.open(request, timeout=30)
except urllib.error.HTTPError as error:
response = error
with response:
body = response.read()
self.send_response(response.status)
for key, value in response.headers.items():
if key.lower() not in ('connection', 'transfer-encoding', 'content-length'):
self.send_header(key, value)
else:
dist = stand.directory / 'frontend'
filename = (dist / urllib.parse.urlparse(self.path).path.lstrip('/')).resolve()
if not filename.is_relative_to(dist.resolve()):
self.send_error(404)
return
if not filename.is_file():
filename = dist / 'index.html'
body = filename.read_bytes()
self.send_response(200)
self.send_header('Content-Type', mimetypes.guess_type(filename)[0] or 'application/octet-stream')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
# Navigation may cancel an in-flight browser polling response.
pass
self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
self.public = f'http://127.0.0.1:{self.server.server_port}'
self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True)
def container(self, suffix, *args):
name = self.name + '-' + suffix
run('docker', 'run', '-d', '--name', name, '--pull=never', '--network', self.name,
'--label', 'ed029-flow=' + self.name, *args)
self.containers.append(name)
return name
def sql(self, mode, sql):
return run('docker', 'exec', '-i', self.name + '-' + mode + '-pg', 'psql', '-U', 'test', '-d', 'test', '-v', 'ON_ERROR_STOP=1', '-At', input=sql)
def start(self):
print('Building current backend/frontend in temporary directory', flush=True)
run('env', 'CGO_ENABLED=0', 'go', 'build', '-o', str(self.directory / 'server'), './cmd/evening_detective_server', cwd=ROOT / 'evening_detective_server')
run('env', 'VITE_API_URL=/.', 'npm', 'run', 'build-only', '--', '--outDir', str(self.directory / 'frontend'), cwd=ROOT / 'evening_detective_frontend')
run('docker', 'network', 'create', '--internal', self.name)
self.network_created = True
assert run('docker', 'network', 'inspect', self.name, '--format', '{{.Internal}}') == 'true'
# SMTP sink accepts and closes connections; no real mail can leave the test network.
self.container('smtp', '--network-alias', 'smtp', 'alpine:3.22', 'nc', '-lk', '-p', '465', '-e', '/bin/true')
for mode in ('prod', 'venue'):
self.container(mode + '-pg', '--network-alias', mode + '-pg', '-e', 'POSTGRES_USER=test', '-e', 'POSTGRES_PASSWORD=test-only', '-e', 'POSTGRES_DB=test', 'postgres:15')
self.container(mode + '-s3', '--network-alias', mode + '-s3', '-e', 'RUSTFS_ACCESS_KEY=test', '-e', 'RUSTFS_SECRET_KEY=test-only-password', '-e', 'RUSTFS_CONSOLE_ENABLE=false', 'rustfs/rustfs:latest')
wait_for(lambda: self.sql(mode, 'SELECT 1;') == '1', mode + ' PostgreSQL')
wait_for(lambda: run('docker', 'exec', self.name + '-' + mode + '-s3', 'curl', '-fsS', 'http://127.0.0.1:9000/health') is not None, mode + ' RustFS')
for migration in sorted((ROOT / 'evening_detective_server/migrations').glob('*.sql')):
self.sql(mode, migration.read_text().split('-- +goose Down')[0])
bootstrap = (ROOT / 'evening_detective_server/scripts/dev-user.sql').read_text()
run('docker', 'exec', '-i', self.name + '-' + mode + '-pg', 'psql', '-U', 'test', '-d', 'test', '-v', 'ON_ERROR_STOP=1',
'-v', 'dev_username=TEST organizer', '-v', 'dev_email=organizer@test.invalid', '-v', 'dev_password=test-only-password', input=bootstrap)
if mode == 'venue':
self.sql(mode, "UPDATE users SET roles='[\"organizer\",\"user\"]'::jsonb;")
self.container(mode + '-app', '-v', str(self.directory / 'server') + ':/server:ro', '-w', '/tmp',
'-e', 'APP_MODE=' + mode, '-e', f'DATABASE_URL=postgresql://test:test-only@{mode}-pg:5432/test?sslmode=disable',
'-e', 'APP_BASE_URL=' + self.public, '-e', 'FILE_PREFIX_DOMAIN=' + self.public + '/api/files/',
'-e', 'JWT_SECRET=test-only-jwt-secret', '-e', 'PARTICIPATION_SECRET=test-only-participation-secret',
'-e', 'S3_HOST=http://' + mode + '-s3:9000', '-e', 'S3_USER=test', '-e', 'S3_PASSWORD=test-only-password', '-e', 'S3_BUCKET=test',
'-e', 'SMTP_HOST=smtp', '-e', 'SMTP_PORT=465', '-e', 'SMTP_USER=test@test.invalid', '-e', 'SMTP_PASSWORD=test-only',
'-e', 'SMTP_TIMEOUT=1s', '-e', 'GAME_TIMEZONE=Asia/Barnaul', 'alpine:3.22', '/server')
address = json.loads(run('docker', 'inspect', self.name + '-' + mode + '-app'))[0]['NetworkSettings']['Networks'][self.name]['IPAddress']
api = self.apis[mode] = API('http://' + address + ':8090')
wait_for(lambda: api.request('/api/test/ping') == {}, mode + ' API')
api.token = api.request('/api/auth/login', {'email': 'organizer@test.invalid', 'password': 'test-only-password'})['accessToken']
assert api.request('/api/ui/permissions')['environment'] == mode
self.server_thread.start()
print('PASS: separate prod/venue DB and RustFS in internal Docker network', flush=True)
def restart(self):
for suffix in ('venue-app', 'venue-pg', 'venue-s3'):
run('docker', 'stop', self.name + '-' + suffix)
for suffix in ('venue-pg', 'venue-s3', 'venue-app'):
run('docker', 'start', self.name + '-' + suffix)
if suffix == 'venue-pg':
wait_for(lambda: self.sql('venue', 'SELECT 1;') == '1', 'restarted PostgreSQL')
elif suffix == 'venue-s3':
wait_for(lambda: run('docker', 'exec', self.name + '-venue-s3', 'curl', '-fsS', 'http://127.0.0.1:9000/health') is not None, 'restarted RustFS')
wait_for(lambda: self.apis['venue'].request('/api/test/ping') == {}, 'restarted venue')
def close(self):
if self.server_thread.is_alive():
self.server.shutdown()
self.server_thread.join(timeout=5)
self.server.server_close()
errors = []
for name in reversed(self.containers):
try:
run('docker', 'rm', '-f', '-v', name)
except RuntimeError as error:
errors.append(str(error))
if self.network_created:
try:
run('docker', 'network', 'rm', self.name)
except RuntimeError as error:
errors.append(str(error))
if errors:
raise RuntimeError('Stand cleanup failed: ' + '; '.join(errors))
def fixture(stand, format):
source = stand.apis['prod']
scenario = source.request('/api/scenario', {'name': 'TEST stage 8 ' + format})['ID']
filename = source.request('/api/files/upload', {'filename': 'stage8.pdf', 'data': base64.b64encode(MATERIAL).decode()})['filename']
source.request(f'/api/scenarios/{scenario}/introduction', {'introduction': {'text': 'TEST Introduction'}}, 'PUT')
for code in ('p1', 'p2'):
source.request(f'/api/scenarios/{scenario}/places', {'place': {'code': code, 'name': 'TEST Place ' + code, 'text': 'TEST Text ' + code,
'applications': [{'name': 'TEST Clue ' + code, 'image': filename}]}})
source.request(f'/api/scenarios/{scenario}/questions', {'question': {'code': 'q1', 'text': 'TEST Who?', 'answer': 'Butler'}})
start = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=48)).isoformat()
game = source.request('/api/games', {'name': 'TEST stage 8 ' + format, 'description': 'Disposable full flow', 'startAt': start, 'scenarioID': scenario, 'format': format, 'location': 'TEST venue'})['ID']
teams = []
for name, participation in [('Captain', 'confirmed'), ('Pending', 'pending'), ('Cancelled', 'cancelled')]:
team = source.request('/api/teams', {'gameID': game, 'name': 'TEST ' + name, 'participantsCount': 2})
teams.append(team)
if participation != 'pending':
# Simulate issuing the email token; decisions themselves use the public API.
token = secrets.token_hex(32)
stand.sql('prod', f"UPDATE teams SET participation_token_hash=decode('{hashlib.sha256(token.encode()).hexdigest()}', 'hex') WHERE id={int(team['ID'])};")
source.request('/api/participation/answer', {'token': token, 'answer': participation})
tickets = source.request(f'/api/games/{game}/tickets', {'count': 5})['tickets']
used = '/api' + urllib.parse.urlparse(tickets[0]['URL']).path
source.request(used + '/accept', {})
result = {'game': game, 'scenario': scenario, 'format': format, 'teams': teams, 'tickets': tickets, 'startAt': start, 'filename': filename}
(stand.directory / f'fixture-{game}.json').write_text(json.dumps(result))
return result
def export_package(api, game):
api.request(f'/api/games/{game}/export', {})
wait_for(lambda: api.game(game).get('export', {}).get('state') == 'ready', 'export ready')
return api.request(f'/api/games/{game}/export/download', raw=True)
def exercise(stand, data):
source, venue = stand.apis['prod'], stand.apis['venue']
game, format = data['game'], data['format']
captain, pending, cancelled = data['teams']
package = data['package']
original = source.game(game)
local = venue.game(game)
assert local['format'] == format and local['status'] == 'draft'
for old, new in zip(original['teams'], local['teams'], strict=True):
for key in ('ID', 'password', 'participationStatus', 'participantsCount'):
assert old[key] == new[key], 'Imported team changed: ' + key
for ticket in data['tickets']:
path = '/api' + urllib.parse.urlparse(ticket['URL']).path
before, after = source.request(path)['ticket'], venue.request(path)['ticket']
assert before['ID'] == after['ID'] and before['status'] == after['status']
stand.current = 'venue'
public = API(stand.public)
assert public.story(captain)['game']['ID'] == game
public.story({'ID': captain['ID'], 'password': 'wrong'}, expected=404)
public.story(cancelled, expected=404)
assert venue.request(f'/api/games/{game}/teams/qr.pdf', raw=True).startswith(b'%PDF-')
source.request(f'/api/teams/{captain["ID"]}', {'name': 'TEST Changed after export'}, 'PUT')
assert venue.game(game)['teams'][0]['name'] != 'TEST Changed after export'
used, active, concurrent, after_finish, unused = ['/api' + urllib.parse.urlparse(t['URL']).path for t in data['tickets']]
venue.request(used + '/accept', {}, expected=400)
assert public.request(active)['ticket']['status'] == 'active'
public.request(active + '/accept', {}, expected=401)
venue.request(active + '/accept', {})
venue.request(active + '/accept', {}, expected=400)
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: venue.request(concurrent + '/accept', {}, expected=(200, 400)), range(2)))
assert sum('ticket' in result for result in results) == 1
rejected = next(result for result in results if 'ticket' not in result)
assert rejected['code'] == 9 and rejected['message'] == 'билет уже использован'
public.request('/api/tickets/invalid', expected=404)
venue.request('/api/tickets/invalid/accept', {}, expected=404)
local_ticket = venue.request(f'/api/games/{game}/tickets', {'count': 1})['tickets'][0]
assert local_ticket['URL'].startswith(stand.public + '/tickets/')
assert public.request('/api' + urllib.parse.urlparse(local_ticket['URL']).path)['ticket']['status'] == 'active'
new_team = venue.request('/api/teams', {'gameID': game, 'name': 'TEST Local team', 'participantsCount': 2})
assert new_team['ID'] > max(t['ID'] for t in data['teams'])
assert public.story(new_team)['game']['ID'] == game
public.move(pending, 'p1', expected=400)
assert next(t for t in venue.game(game)['teams'] if t['ID'] == pending['ID'])['participationStatus'] == 'pending'
venue.request(f'/api/games/{game}/start', {}, 'PUT')
public.move(cancelled, 'p1', expected=404)
public.move({'ID': captain['ID'], 'password': 'wrong'}, 'p1', expected=404)
public.move(pending, 'p1')
public.move(new_team, 'p1')
confirmed = next(t for t in venue.game(game)['teams'] if t['ID'] == pending['ID'])
assert confirmed['participationStatus'] == 'confirmed' and confirmed['participationSource'] == 'first_action'
public.move(captain, 'p1')
view = public.story(captain)
assert len(view['actions']) == 1 and view['story']['places'][0]['code'] == 'p1'
assert not view['story'].get('conclusion')
clue = next(t for t in venue.game(game)['teams'] if t['ID'] == captain['ID'])['applications'][0]
assert OPENER.open(clue['image'], timeout=10).read() == MATERIAL
if format == 'offline':
assert not view['story'].get('questions') and not view['story'].get('introduction')
assert not view['story']['places'][0]['applications'][0].get('image')
else:
assert view['story']['introduction']['text'] == 'TEST Introduction'
assert not view['story']['questions'][0].get('answer')
venue.request(f'/api/teams/{captain["ID"]}/applications', {'name': clue['name']})
assert not next(t for t in venue.game(game)['teams'] if t['ID'] == captain['ID'])['applications']
query = urllib.parse.urlencode({'password': captain['password']})
answers_path = f'/api/teams/{captain["ID"]}/answers'
answers = {'password': captain['password'], 'answers': [{'questionCode': 'q1', 'answer': 'Butler'}]}
public.request(answers_path, answers, expected=400 if format == 'offline' else 200)
venue.request(f'/api/games/{game}/pause', {}, 'PUT')
public.move(captain, 'p2', expected=400)
assert len(public.story(captain)['actions']) == 1
stand.restart()
assert venue.game(game)['status'] == 'pause'
assert len(public.story(captain)['actions']) == 1
assert public.request(active)['ticket']['status'] == 'used'
assert OPENER.open(clue['image'], timeout=10).read() == MATERIAL
if format == 'online':
assert public.request(answers_path + '?' + query)['answers'][0]['answer'] == 'Butler'
assert venue.request('/api/games/import', package)['repeated']
assert venue.game(game)['status'] == 'pause'
venue.request(f'/api/games/{game}/play', {}, 'PUT')
public.move(captain, 'p2')
assert len(public.story(captain)['actions']) == 2
venue.request(f'/api/games/{game}/finish', {}, 'PUT')
public.move(captain, 'p1', expected=400)
public.request(answers_path, answers, expected=400)
venue.request(after_finish + '/accept', {}, expected=400)
finished = public.story(captain)
assert finished['game']['status'] == 'finish' and not finished.get('actions') and not finished['story'].get('places')
venue.request(f'/api/teams/{captain["ID"]}/answers/q1/score', {'score': 5}, 'PUT')
results = venue.request(f'/api/games/{game}/results')
scored = next(t for t in results['teams'] if t['team']['ID'] == captain['ID'])
assert scored['totalScore'] == 5 and scored['team']['actionsCount'] == 2
assert all(t['team']['ID'] != cancelled['ID'] for t in results['teams'])
before_repeat = venue.game(game)
assert venue.request('/api/games/import', package)['repeated']
assert venue.game(game) == before_repeat
assert source.game(game)['status'] == 'draft'
assert source.request(active)['ticket']['status'] == 'active'
assert not next(t for t in source.game(game)['teams'] if t['ID'] == captain['ID'])['actionsCount']
print('PASS: ' + format + ' export/import, original links, tickets, teams, gameplay, restart, results and repeat', flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--browser', action='store_true', help='Also run Firefox against the real APIs (Firefox required)')
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix='ed029-flow-') as directory:
stand = Stand(directory)
try:
stand.start()
pg_address = json.loads(run('docker', 'inspect', stand.name + '-venue-pg'))[0]['NetworkSettings']['Networks'][stand.name]['IPAddress']
run('env', f'TEST_DATABASE_URL=host={pg_address} user=test password=test-only dbname=test sslmode=disable',
'go', 'test', './internal/services/game_service', './internal/services/gameplay_service', './internal/services/mcp_service', './internal/repos/tickets_repo', './internal/app', cwd=ROOT / 'evening_detective_server')
print('PASS: gameplay/lifecycle/MCP/app tests with isolated PostgreSQL', flush=True)
fixtures = [fixture(stand, format) for format in ('offline', 'online')]
for data in fixtures:
package = data['package'] = export_package(stand.apis['prod'], data['game'])
(stand.directory / f'game-{data["game"]}.zip').write_bytes(package)
imported = stand.apis['venue'].request('/api/games/import', package)
assert imported['gameID'] == data['game'] and not imported['repeated']
for data in fixtures:
exercise(stand, data)
finally:
stand.close()
if args.browser:
from flow_browser import check_browser_flow
with tempfile.TemporaryDirectory(prefix='ed029-browser-flow-') as directory:
stand = Stand(directory)
try:
stand.start()
check_browser_flow(stand, [fixture(stand, format) for format in ('online', 'offline')])
finally:
stand.close()
print('PASS: stage 8 automated checks; phone/camera/no-WAN acceptance remains manual')
if __name__ == '__main__':
main()
+210
View File
@@ -0,0 +1,210 @@
"""Firefox UI checks against the disposable real servers started by flow.py."""
import json
import socket
import subprocess
import time
import urllib.parse
def check_browser_flow(stand, fixtures):
profile = stand.directory / 'firefox'
profile.mkdir()
downloads = stand.directory / 'downloads'
downloads.mkdir()
with socket.socket() as reservation:
reservation.bind(('127.0.0.1', 0))
port = reservation.getsockname()[1]
(profile / 'user.js').write_text('\n'.join([
f'user_pref("marionette.port", {port});',
'user_pref("browser.shell.checkDefaultBrowser", false);',
'user_pref("browser.tabs.warnOnClose", false);',
'user_pref("browser.download.folderList", 2);',
f'user_pref("browser.download.dir", {json.dumps(str(downloads))});',
'user_pref("browser.download.useDownloadDir", true);',
'user_pref("browser.helperApps.neverAsk.saveToDisk", "application/zip,application/octet-stream");',
'user_pref("browser.download.always_ask_before_handling_new_types", false);',
]))
sock = None
with (stand.directory / 'firefox.log').open('w') as log:
firefox = subprocess.Popen(['firefox', '--headless', '--no-remote', '--profile', str(profile), '--marionette', '--remote-allow-system-access', 'about:blank'], stdout=log, stderr=log)
try:
deadline = time.monotonic() + 15
while sock is None and time.monotonic() < deadline:
try:
sock = socket.create_connection(('127.0.0.1', port), timeout=1)
except OSError:
time.sleep(.1)
assert sock is not None, 'Firefox Marionette did not start'
sock.settimeout(30)
def read_message():
size = b''
while not size.endswith(b':'):
part = sock.recv(1)
assert part, 'Firefox disconnected'
size += part
remaining = int(size[:-1])
data = b''
while remaining:
part = sock.recv(remaining)
assert part, 'Firefox disconnected'
data += part
remaining -= len(part)
return json.loads(data)
read_message()
command_id = 0
def command(name, args=None):
nonlocal command_id
command_id += 1
payload = json.dumps([0, command_id, name, args or {}]).encode()
sock.sendall(str(len(payload)).encode() + b':' + payload)
result = read_message()
if result[2]:
raise RuntimeError('Firefox command failed: ' + name)
return result[3]
def js(script, *args):
result = command('WebDriver:ExecuteScript', {'script': script, 'args': list(args), 'newSandbox': False, 'sandbox': None})
return result.get('value') if isinstance(result, dict) and 'value' in result else result
def wait(script, description, seconds=15):
until = time.monotonic() + seconds
while time.monotonic() < until:
if js('return ' + script):
return
time.sleep(.1)
raise AssertionError('Browser timed out: ' + description)
def navigate(path):
command('WebDriver:Navigate', {'url': stand.public + path})
def click(label):
assert js("const button=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()===arguments[0] || b.getAttribute('aria-label')===arguments[0]);if(!button || button.disabled)return false;button.click();return true;", label), 'Missing or disabled button: ' + label
def input_value(selector, value):
assert js("const input=document.querySelector(arguments[0]);if(!input)return false;input.value=arguments[1];input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));return true;", selector, value), 'Missing input: ' + selector
def login(mode):
stand.current = mode
navigate('/login')
js('localStorage.clear();return true;')
navigate('/login')
wait("document.querySelector('input[type=password]')!==null", 'login form')
input_value('input[placeholder="detective@mail.ru"]', 'organizer@test.invalid')
input_value('input[type=password]', 'test-only-password')
click('Вход')
wait("location.pathname==='/office'", 'login')
command('WebDriver:NewSession', {'capabilities': {'alwaysMatch': {'acceptInsecureCerts': True}}})
for width, data in zip((1440, 390), fixtures, strict=True):
game, captain = data['game'], data['teams'][0]
command('WebDriver:SetWindowRect', {'width': width, 'height': 900})
login('prod')
navigate(f'/games/{game}?tab=settings')
wait("document.body.innerText.includes('Подготовить пакет')", 'export button')
click('Подготовить пакет')
wait("document.body.innerText.includes('Скачать пакет')", 'export ready', 60)
previous_downloads = {p.name for p in downloads.glob('*.zip')}
click('Скачать пакет')
until = time.monotonic() + 15
package = None
while time.monotonic() < until:
candidates = [p for p in downloads.glob('*.zip') if p.name not in previous_downloads and p.stat().st_size and not p.with_suffix(p.suffix + '.part').exists()]
if candidates:
package = max(candidates, key=lambda p: p.stat().st_mtime)
break
time.sleep(.1)
assert package is not None, 'ZIP download missing'
raw = package.read_bytes()
assert raw.startswith(b'PK'), 'Downloaded file is not ZIP'
login('venue')
navigate('/games')
wait("document.body.innerText.includes('Импорт игры')", 'import button')
click('Импорт игры')
js("const dt=new DataTransfer();dt.items.add(new File([new Uint8Array(arguments[0])],arguments[1],{type:'application/zip'}));const input=document.querySelector('input[type=file]');input.files=dt.files;input.dispatchEvent(new Event('change',{bubbles:true}));return true;", list(raw), package.name)
click('Загрузить пакет')
wait("document.body.innerText.includes('Игра импортирована')", 'real ZIP import')
assert js("const r=document.querySelector('[role=dialog]').getBoundingClientRect();return r.left>=0 && r.right<=innerWidth"), 'Import modal overflows viewport'
command('WebDriver:PerformActions', {'actions': [{'type': 'key', 'id': 'keyboard', 'actions': [{'type': 'keyDown', 'value': '\ue004'}, {'type': 'keyUp', 'value': '\ue004'}]}]})
assert js("return document.querySelector('[role=dialog]').contains(document.activeElement)"), 'Modal lost keyboard focus'
click('Открыть игру')
wait(f"location.pathname==='/games/{game}'", 'open imported game')
navigate(f'/games/{game}?tab=payment')
wait("document.body.innerText.includes('Проверить билет')", 'ticket checking')
click('Проверить билет')
input_value('input[placeholder="Или вставьте ссылку на билет"]', data['tickets'][1]['URL'])
assert js("const button=[...document.querySelectorAll('[role=dialog] button')].find(b=>b.textContent.trim()==='Проверить билет');button.click();return true;")
wait("document.body.innerText.includes('Принять билет')", 'active ticket')
click('Принять билет')
wait("document.body.innerText.includes('уже использован')", 'accepted ticket')
navigate(urllib.parse.urlparse(data['tickets'][1]['URL']).path)
wait("document.body.innerText.includes('Билет уже использован')", 'original ticket page')
navigate(f'/games/{game}?tab=game')
wait("document.querySelector('[aria-label=\"Начать игру\"]')!==null", 'start control')
click('Начать игру')
wait("document.querySelector('[aria-label=\"Поставить игру на паузу\"]')!==null", 'running game')
team_path = f'/team-story/{captain["ID"]}?' + urllib.parse.urlencode({'password': captain['password']})
navigate(team_path)
wait("document.querySelector('#run')!==null && !document.querySelector('#run').disabled", 'team input')
stand.delay_actions = .4
input_value('#run', 'p1')
click('Поехали')
assert js("return document.querySelector('.game-button-run').disabled"), 'Move submission is not blocked'
js("document.querySelector('.game-button-run').click();return true;")
wait("document.body.innerText.includes('TEST Place p1')", 'first move')
assert len(stand.apis['venue'].story(captain)['actions']) == 1, 'Double submission created duplicate moves'
stand.sql('venue', f"UPDATE teams SET password='revoked' WHERE id={int(captain['ID'])};")
input_value('#run', 'p2')
click('Поехали')
wait("document.body.innerText.includes('команда не найдена')", 'refused move error')
assert js("return document.querySelector('#run').value==='p2'"), 'Refused move erased the input'
escaped_password = captain['password'].replace("'", "''")
stand.sql('venue', f"UPDATE teams SET password='{escaped_password}' WHERE id={int(captain['ID'])};")
stand.delay_actions = 0
stand.apis['venue'].request(f'/api/games/{game}/pause', {}, 'PUT')
wait("document.querySelector('#run').disabled", 'pause polling')
assert js("return document.querySelector('.game-button-run').disabled"), 'Prefilled move is still enabled during pause'
stand.apis['venue'].request(f'/api/games/{game}/play', {}, 'PUT')
wait("!document.querySelector('.game-button-run').disabled", 'resume polling')
click('Поехали')
wait("document.body.innerText.includes('TEST Place p2')", 'second move')
if data['format'] == 'online':
input_value('.questions-block textarea', 'Butler')
click('Сохранить ответы')
wait("document.body.innerText.includes('Ответы сохранены')", 'answer submission')
else:
assert js("return document.querySelector('.questions-block')===null"), 'Offline game exposes digital answers'
navigate(f'/games/{game}?tab=teams')
wait("document.body.innerText.includes('Выдать: TEST Clue p1')", 'pending clue')
click('Выдать: TEST Clue p1')
wait("document.querySelector('[role=dialog]')!==null", 'clue dialog')
click('Выдать')
navigate(f'/games/{game}?tab=game')
wait("document.querySelector('[aria-label=\"Завершить игру\"]')!==null", 'finish control')
click('Завершить игру')
navigate(f'/games/{game}?tab=answers')
wait("document.querySelector('.answers-score-input input')!==null", 'grading form')
input_value('.answers-score-input input', '5')
click('Сохранить баллы')
wait("document.body.innerText.includes('Баллы сохранены')", 'saved scores')
navigate(f'/games/{game}/results')
wait("document.body.innerText.includes('TEST Captain')", 'results')
navigate(team_path)
wait("document.body.innerText.includes('Игра завершена')", 'finished player view')
assert js("return document.querySelector('.questions-block')===null"), 'Finished game exposes questions'
navigate(f'/team-story/{captain["ID"]}?password=wrong')
wait("document.body.innerText.includes('Не удалось загрузить дело')", 'wrong-password error')
print(f'PASS: real Firefox {width}px {data["format"]}: export/download/import, tickets, moves, pause, clues, grading/results, errors and keyboard', flush=True)
command('Marionette:Quit', {'flags': ['eForceQuit']})
finally:
if sock:
sock.close()
firefox.terminate()
try:
firefox.wait(timeout=5)
except subprocess.TimeoutExpired:
firefox.kill()
firefox.wait(timeout=5)