From 5973eaf072cc9b64849c3765b289ffe21c4a73ec Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 11 Oct 2026 03:05:52 +0700 Subject: [PATCH] up --- docs/agent/TASKS.md | 11 +- prod | 2 +- tests/venue/README.md | 58 +++++ tests/venue/flow.py | 407 ++++++++++++++++++++++++++++++++++++ tests/venue/flow_browser.py | 210 +++++++++++++++++++ 5 files changed, 684 insertions(+), 4 deletions(-) create mode 100644 tests/venue/flow.py create mode 100644 tests/venue/flow_browser.py diff --git a/docs/agent/TASKS.md b/docs/agent/TASKS.md index ef2860c..2ed4db0 100644 --- a/docs/agent/TASKS.md +++ b/docs/agent/TASKS.md @@ -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 — Общая дизайн-система фронтенда diff --git a/prod b/prod index 5ea5af1..46a9864 160000 --- a/prod +++ b/prod @@ -1 +1 @@ -Subproject commit 5ea5af1fcae875648d92600f6c7b5ee104008295 +Subproject commit 46a9864c8c5197583750e26ff7ac3337764ecd0a diff --git a/tests/venue/README.md b/tests/venue/README.md index b462f82..9f6e102 100644 --- a/tests/venue/README.md +++ b/tests/venue/README.md @@ -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 ГиБ и возврат результатов +относятся к отдельным проверкам/этапам. До готовности возврата использовать +только тестовые игры. diff --git a/tests/venue/flow.py b/tests/venue/flow.py new file mode 100644 index 0000000..d7497bb --- /dev/null +++ b/tests/venue/flow.py @@ -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() diff --git a/tests/venue/flow_browser.py b/tests/venue/flow_browser.py new file mode 100644 index 0000000..db844c3 --- /dev/null +++ b/tests/venue/flow_browser.py @@ -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)