up
This commit is contained in:
+8
-3
@@ -27,13 +27,13 @@
|
||||
|
||||
### ED-029 — Автономная площадка и панель организатора
|
||||
|
||||
- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–6 реализованы и проверены, следующий — интерфейс передачи пакета.
|
||||
- Статус: `IN_PROGRESS` — этап 3 принят пользователем без WAN; этапы 4–7 завершены; автоматическая часть этапа 8 завершена, ожидает аппаратной приёмки.
|
||||
- Репозитории: корневой, `evening_detective_server`, `evening_detective_frontend`.
|
||||
- Цель: реализовать принятый пользователем план из 15 последовательных этапов: автономное окружение; локальные организаторы; прежние адреса через Cudy; пакет игры; экспорт со статусом офлайн-режима; локальный импорт; интерфейс передачи; полный билетный и игровой flow; пакет результатов; возврат онлайн; интерфейс возврата; устройства; панель состояния; сбор данных Cudy; Wi-Fi и испытание площадки.
|
||||
- Критерии: проверяемый результат каждого этапа; цикл «онлайн → ноутбук → проведение → онлайн» через интерфейс с сохранением QR, билетов и связей; повторные импорты без дублей; мониторинг устройств и Wi-Fi; полный прогон без WAN, 100 клиентов и проверка покрытия.
|
||||
- Ограничения: только тестовые игры до завершения возврата; существующие правила игры и формат `offline` сохраняются; без несогласованных новых зависимостей и отклонений от стандартов; без персональных списков игроков, CPU/памяти Cudy и автоматического переключения серверов; настройки роутера менять только по явному запросу пользователя; секреты и пакеты вне Git; commit/push выполняет пользователь.
|
||||
- Проверка: 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`.
|
||||
- Упрощение запуска: отдельные `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` не изменялась.
|
||||
@@ -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 не выполняются.
|
||||
- Результат этапа 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 не выполнялись.
|
||||
- Следующий шаг: этап 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 — Общая дизайн-система фронтенда
|
||||
|
||||
|
||||
+1
-1
Submodule prod updated: 5ea5af1fca...46a9864c8c
@@ -27,3 +27,61 @@ python3 tests/venue/https.py --docker
|
||||
python3 tests/venue/dns.py --docker
|
||||
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 ГиБ и возврат результатов
|
||||
относятся к отдельным проверкам/этапам. До готовности возврата использовать
|
||||
только тестовые игры.
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user