e6fae2907d
Blazor Server's default change-event binding races with Playwright fills, causing input values to revert before the form submits. Switch Title, JoinLink and MaxPlayers to @bind-Value:event=oninput so the model stays in sync while the test types. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
443 lines
16 KiB
Python
443 lines
16 KiB
Python
"""
|
|
Playwright E2E tests for the GmRelay Blazor/Web dashboard.
|
|
|
|
These tests use a mocked Telegram Mini App initData payload so they can run
|
|
locally against a real GmRelay.Web instance without talking to Telegram.
|
|
|
|
Prerequisites:
|
|
pip install playwright
|
|
playwright install chromium
|
|
|
|
Environment:
|
|
GMRELAY_E2E_BASE_URL - Web dashboard URL (default: http://localhost:8080)
|
|
GMRELAY_E2E_BOT_TOKEN - Bot token matching the target Web instance
|
|
GMRELAY_E2E_TELEGRAM_ID - Telegram ID to use for the mocked user
|
|
GMRELAY_E2E_DATABASE_URL - PostgreSQL DSN for DB assertions (optional)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from playwright.sync_api import Page, expect, sync_playwright
|
|
|
|
import sys
|
|
import pathlib
|
|
|
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
|
|
from helpers.telegram_init_data import build_mini_app_init_data
|
|
|
|
|
|
def _base_url() -> str:
|
|
return os.environ.get("GMRELAY_E2E_BASE_URL", "http://localhost:8080").rstrip("/")
|
|
|
|
|
|
def _bot_token() -> str:
|
|
token = os.environ.get("GMRELAY_E2E_BOT_TOKEN")
|
|
if not token:
|
|
raise RuntimeError(
|
|
"GMRELAY_E2E_BOT_TOKEN is required. It must match the bot token used by the target Web instance."
|
|
)
|
|
return token
|
|
|
|
|
|
def _telegram_id() -> int:
|
|
return int(os.environ.get("GMRELAY_E2E_TELEGRAM_ID", "9000000001"))
|
|
|
|
|
|
def _database_url() -> Optional[str]:
|
|
return os.environ.get("GMRELAY_E2E_DATABASE_URL")
|
|
|
|
|
|
def _seed_group_in_database(
|
|
telegram_id: int,
|
|
group_id: str,
|
|
group_name: str,
|
|
session_id: str,
|
|
session_title: str,
|
|
batch_id: Optional[str] = None,
|
|
) -> None:
|
|
"""
|
|
Insert minimal test data via the shared Npgsql connection so the dashboard
|
|
has something to render. This is intentionally thin: full state setup is
|
|
performed by the console runner in issue #151.
|
|
"""
|
|
import psycopg2
|
|
|
|
dsn = _database_url()
|
|
if not dsn:
|
|
return # rely on a pre-seeded dev database
|
|
|
|
platform = "Telegram"
|
|
external_user_id = str(telegram_id)
|
|
now = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
|
|
scheduled_at = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() + 86400))
|
|
batch_id = batch_id or str(uuid.uuid4())
|
|
|
|
with psycopg2.connect(dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO public.players (platform, external_user_id, display_name, created_at)
|
|
VALUES (%s, %s, %s, %s)
|
|
ON CONFLICT (platform, external_user_id)
|
|
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
|
DO UPDATE SET display_name = EXCLUDED.display_name
|
|
RETURNING id
|
|
""",
|
|
(platform, external_user_id, "E2E Test GM", now),
|
|
)
|
|
player_id = cur.fetchone()[0]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO public.game_groups (id, platform, external_group_id, telegram_chat_id, name, created_at)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
|
|
RETURNING id
|
|
""",
|
|
(group_id, platform, "-1001234567890", -1001234567890, group_name, now),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO public.group_managers (group_id, player_id, role, created_at)
|
|
VALUES (%s, %s, %s, %s)
|
|
ON CONFLICT (group_id, player_id) DO UPDATE SET role = EXCLUDED.role
|
|
""",
|
|
(group_id, player_id, "Owner", now),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO public.sessions (
|
|
id, group_id, batch_id, title, scheduled_at, join_link, status, publication_mode,
|
|
notification_mode, max_players, format, location_address, system, created_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
scheduled_at = EXCLUDED.scheduled_at,
|
|
join_link = EXCLUDED.join_link,
|
|
max_players = EXCLUDED.max_players,
|
|
publication_mode = EXCLUDED.publication_mode,
|
|
format = EXCLUDED.format,
|
|
location_address = EXCLUDED.location_address,
|
|
system = EXCLUDED.system
|
|
""",
|
|
(
|
|
session_id,
|
|
group_id,
|
|
batch_id,
|
|
session_title,
|
|
scheduled_at,
|
|
"https://example.com/join",
|
|
"Planned",
|
|
"None",
|
|
"GroupAndDirect",
|
|
5,
|
|
"Online",
|
|
"Discord",
|
|
"D&D 5e",
|
|
now,
|
|
),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO public.session_participants (session_id, player_id, registration_status, rsvp_status, is_gm, created_at)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT (session_id, player_id) DO NOTHING
|
|
""",
|
|
(session_id, player_id, "Active", "Pending", True, now),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
|
|
def _get_session_from_db(session_id: str):
|
|
import psycopg2
|
|
|
|
dsn = _database_url()
|
|
if not dsn:
|
|
return None
|
|
|
|
with psycopg2.connect(dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
SELECT title, join_link, max_players, publication_mode, format, location_address, system, status
|
|
FROM public.sessions
|
|
WHERE id = %s
|
|
""",
|
|
(session_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row is None:
|
|
return None
|
|
return {
|
|
"title": row[0],
|
|
"join_link": row[1],
|
|
"max_players": row[2],
|
|
"publication_mode": row[3],
|
|
"format": row[4],
|
|
"location_address": row[5],
|
|
"system": row[6],
|
|
"status": row[7],
|
|
}
|
|
|
|
|
|
def _assert_session_deleted(session_id: str) -> None:
|
|
import psycopg2
|
|
|
|
dsn = _database_url()
|
|
if not dsn:
|
|
return
|
|
|
|
with psycopg2.connect(dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT COUNT(*) FROM public.sessions WHERE id = %s", (session_id,))
|
|
assert cur.fetchone()[0] == 0, f"Session {session_id} was not deleted"
|
|
|
|
|
|
def _delete_test_data(group_id: str, session_id: str) -> None:
|
|
import psycopg2
|
|
|
|
dsn = _database_url()
|
|
if not dsn:
|
|
return
|
|
|
|
telegram_id = str(_telegram_id())
|
|
with psycopg2.connect(dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute("DELETE FROM public.session_participants WHERE session_id = %s", (session_id,))
|
|
cur.execute("DELETE FROM public.sessions WHERE id = %s", (session_id,))
|
|
cur.execute("DELETE FROM public.group_managers WHERE group_id = %s", (group_id,))
|
|
cur.execute("DELETE FROM public.game_groups WHERE id = %s", (group_id,))
|
|
|
|
# Only remove the player if the test created it. If the Telegram ID
|
|
# belongs to a real production user (e.g. the Toutsu account), leave
|
|
# the row intact so we do not break that account.
|
|
cur.execute(
|
|
"SELECT id FROM public.players WHERE platform = 'Telegram' AND external_user_id = %s",
|
|
(telegram_id,),
|
|
)
|
|
if cur.fetchone() is not None:
|
|
# Determine whether this player has any other groups or sessions.
|
|
cur.execute(
|
|
"SELECT COUNT(*) FROM public.group_managers m "
|
|
"JOIN public.players p ON p.id = m.player_id "
|
|
"WHERE p.platform = 'Telegram' AND p.external_user_id = %s",
|
|
(telegram_id,),
|
|
)
|
|
manager_count = cur.fetchone()[0]
|
|
cur.execute(
|
|
"SELECT COUNT(*) FROM public.session_participants sp "
|
|
"JOIN public.players p ON p.id = sp.player_id "
|
|
"WHERE p.platform = 'Telegram' AND p.external_user_id = %s",
|
|
(telegram_id,),
|
|
)
|
|
participant_count = cur.fetchone()[0]
|
|
if manager_count == 0 and participant_count == 0:
|
|
cur.execute(
|
|
"DELETE FROM public.players WHERE platform = 'Telegram' AND external_user_id = %s",
|
|
(telegram_id,),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _authenticate_page(page: Page, base_url: str, bot_token: str, telegram_id: int) -> None:
|
|
"""
|
|
Authenticate by calling /auth/telegram-webapp with a valid initData payload.
|
|
The response sets the auth cookie; subsequent page navigations are authenticated.
|
|
"""
|
|
init_data = build_mini_app_init_data(
|
|
bot_token=bot_token,
|
|
telegram_id=telegram_id,
|
|
first_name="E2E",
|
|
last_name="Test",
|
|
username="e2e_test",
|
|
)
|
|
|
|
response = page.request.post(
|
|
f"{base_url}/auth/telegram-webapp",
|
|
data={"initData": init_data.init_data_raw},
|
|
)
|
|
expect(response).to_be_ok()
|
|
page.goto(base_url)
|
|
|
|
|
|
def _wait_for_blazor(page: Page) -> None:
|
|
"""Wait until Blazor has finished the initial render."""
|
|
page.wait_for_selector("text=Добро пожаловать", timeout=15000)
|
|
|
|
|
|
def test_dashboard_authenticates_and_shows_groups() -> None:
|
|
base_url = _base_url()
|
|
bot_token = _bot_token()
|
|
telegram_id = _telegram_id()
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True)
|
|
context = browser.new_context()
|
|
page = context.new_page()
|
|
page.set_viewport_size({"width": 1920, "height": 1080})
|
|
|
|
_authenticate_page(page, base_url, bot_token, telegram_id)
|
|
_wait_for_blazor(page)
|
|
|
|
heading = page.locator("h2")
|
|
expect(heading).to_contain_text("Добро пожаловать")
|
|
|
|
browser.close()
|
|
|
|
|
|
def test_dashboard_session_edit_flow() -> None:
|
|
base_url = _base_url()
|
|
bot_token = _bot_token()
|
|
telegram_id = _telegram_id()
|
|
|
|
group_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"
|
|
session_id = "bbbbbbbb-bbbb-bbbb-bbbb-aaaaaaaaaaa1"
|
|
batch_id = "cccccccc-cccc-cccc-cccc-aaaaaaaaaaa1"
|
|
original_title = "E2E Original Title"
|
|
updated_title = "E2E Updated Title"
|
|
updated_join_link = "https://example.com/updated-join"
|
|
|
|
try:
|
|
_seed_group_in_database(
|
|
telegram_id=telegram_id,
|
|
group_id=group_id,
|
|
group_name="E2E Test Group",
|
|
session_id=session_id,
|
|
session_title=original_title,
|
|
batch_id=batch_id,
|
|
)
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True)
|
|
context = browser.new_context()
|
|
page = context.new_page()
|
|
page.set_viewport_size({"width": 1920, "height": 1080})
|
|
|
|
_authenticate_page(page, base_url, bot_token, telegram_id)
|
|
_wait_for_blazor(page)
|
|
|
|
page.goto(f"{base_url}/group/{group_id}")
|
|
page.wait_for_selector(f"text={original_title}", timeout=15000)
|
|
|
|
page.goto(f"{base_url}/session/edit/{session_id}")
|
|
page.wait_for_selector("text=Редактирование сессии", timeout=15000)
|
|
|
|
def _fill_blazor_input(locator, value: str) -> None:
|
|
"""Fill a Blazor-bound input, move focus out, and wait for the value to stick."""
|
|
locator.fill(value)
|
|
locator.press("Tab")
|
|
# Blazor Server round-trips on change; give it time to update the model
|
|
# and re-render before the next field is touched.
|
|
page.wait_for_timeout(500)
|
|
expect(locator).to_have_value(value)
|
|
|
|
title_input = page.locator(".gm-form-group").filter(has_text="Название игры").locator("input").first
|
|
_fill_blazor_input(title_input, updated_title)
|
|
|
|
join_input = page.locator(".gm-form-group").filter(has_text="Ссылка для подключения").locator("input").first
|
|
_fill_blazor_input(join_input, updated_join_link)
|
|
|
|
max_players_input = page.locator(".gm-form-group").filter(has_text="Лимит мест").locator("input").first
|
|
_fill_blazor_input(max_players_input, "3")
|
|
|
|
# Verify the form actually received the new values before Blazor submits.
|
|
filled_title = title_input.input_value()
|
|
filled_join = join_input.input_value()
|
|
filled_max = max_players_input.input_value()
|
|
print(f"Edit form values before save: title={filled_title!r}, join={filled_join!r}, max={filled_max!r}")
|
|
|
|
save_button = page.locator("button:has-text('Сохранить изменения')").first
|
|
save_button.click()
|
|
|
|
page.wait_for_url(f"{base_url}/group/{group_id}", timeout=15000)
|
|
try:
|
|
page.wait_for_selector(f"text={updated_title}", timeout=15000)
|
|
except Exception:
|
|
dump_path = "e2e_edit_group_debug.html"
|
|
with open(dump_path, "w", encoding="utf-8") as f:
|
|
f.write(page.content())
|
|
print("URL after edit save:", page.url)
|
|
print(f"Group page HTML dumped to {dump_path}")
|
|
page.screenshot(path="e2e_edit_group_debug.png")
|
|
raise
|
|
expect(page.locator(f"text={updated_title}").first).to_be_visible()
|
|
|
|
db_session = _get_session_from_db(session_id)
|
|
if db_session is not None:
|
|
assert db_session["title"] == updated_title
|
|
assert db_session["join_link"] == updated_join_link
|
|
assert db_session["max_players"] == 3
|
|
|
|
browser.close()
|
|
finally:
|
|
_delete_test_data(group_id, session_id)
|
|
|
|
|
|
def test_dashboard_session_delete_flow() -> None:
|
|
base_url = _base_url()
|
|
bot_token = _bot_token()
|
|
telegram_id = _telegram_id()
|
|
|
|
group_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"
|
|
session_id = "bbbbbbbb-bbbb-bbbb-bbbb-aaaaaaaaaaa2"
|
|
batch_id = "cccccccc-cccc-cccc-cccc-aaaaaaaaaaa2"
|
|
session_title = "E2E Delete Target"
|
|
|
|
try:
|
|
_seed_group_in_database(
|
|
telegram_id=telegram_id,
|
|
group_id=group_id,
|
|
group_name="E2E Delete Group",
|
|
session_id=session_id,
|
|
session_title=session_title,
|
|
batch_id=batch_id,
|
|
)
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True)
|
|
context = browser.new_context()
|
|
page = context.new_page()
|
|
page.set_viewport_size({"width": 1920, "height": 1080})
|
|
|
|
_authenticate_page(page, base_url, bot_token, telegram_id)
|
|
_wait_for_blazor(page)
|
|
|
|
page.goto(f"{base_url}/group/{group_id}")
|
|
page.wait_for_selector(f"text={session_title}", timeout=15000)
|
|
|
|
page.on("dialog", lambda dialog: dialog.accept())
|
|
|
|
delete_button = page.locator(".session-card-mobile button:has-text('Удалить')").first
|
|
expect(delete_button).to_be_visible()
|
|
delete_button.click()
|
|
|
|
page.wait_for_selector("text=Сессия удалена.", timeout=15000)
|
|
expect(page.locator(f"text={session_title}")).to_have_count(0)
|
|
|
|
_assert_session_deleted(session_id)
|
|
|
|
browser.close()
|
|
finally:
|
|
_delete_test_data(group_id, session_id)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_dashboard_authenticates_and_shows_groups()
|
|
print("PASS test_dashboard_authenticates_and_shows_groups")
|
|
test_dashboard_session_edit_flow()
|
|
print("PASS test_dashboard_session_edit_flow")
|
|
test_dashboard_session_delete_flow()
|
|
print("PASS test_dashboard_session_delete_flow")
|