This commit is contained in:
2026-10-11 14:17:03 +07:00
parent c37d76937a
commit 2b7b95fe2b
6 changed files with 121 additions and 9 deletions
+74 -4
View File
@@ -63,7 +63,7 @@ def check_browser_flow(stand, fixtures):
sock.sendall(str(len(payload)).encode() + b':' + payload)
result = read_message()
if result[2]:
raise RuntimeError('Firefox command failed: ' + name)
raise RuntimeError('Firefox command failed: ' + name + ': ' + result[2].get('message', 'unknown error'))
return result[3]
def js(script, *args):
@@ -87,13 +87,13 @@ def check_browser_flow(stand, fixtures):
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):
def login(mode, email='organizer@test.invalid'):
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[placeholder="detective@mail.ru"]', email)
input_value('input[type=password]', 'test-only-password')
click('Вход')
wait("location.pathname==='/office'", 'login')
@@ -132,6 +132,11 @@ def check_browser_flow(stand, fixtures):
js("window.dispatchEvent(new Event('online'));return true;")
wait_for(lambda: any(c['ID'] == screen_connection['ID'] and c.get('gameID') == data['game'] for c in connections()), 'bound screen presence')
assert {c['kind'] for c in connections()} >= {'organizer', 'team', 'screen'}
command('WebDriver:SwitchToWindow', {'handle': organizer_window})
assert js("const tab=[...document.querySelectorAll('.n-tabs-tab[data-name]')].find(t=>t.textContent.trim()==='Состояние');tab.focus();tab.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));return true;")
wait("document.body.innerText.includes('Подключения: 3')", 'three connections in one device panel')
assert js("return [...document.querySelectorAll('.device-details strong')].filter(e=>e.textContent===arguments[0]).length===1", device_id)
command('WebDriver:SwitchToWindow', {'handle': screen_window})
command('WebDriver:CloseWindow')
command('WebDriver:SwitchToWindow', {'handle': team_window})
command('WebDriver:CloseWindow')
@@ -157,13 +162,75 @@ def check_browser_flow(stand, fixtures):
assert new_id != old_id and js("return localStorage.getItem('evening-detective-device-ID')") == device_id
print('PASS: stage 12 real Firefox organizer/player/screen, grouped tabs, SPA context clearing, reload UUID, stopped heartbeat/network recovery and interval re-registration after restart', flush=True)
def check_state(data):
from flow import API, ROOT, run
game = data['game']
navigate(f'/games/{game}?tab=state')
wait("document.body.innerText.includes('Последнее обновление:')", 'state initial snapshot')
assert js("return document.body.innerText.includes('Доступны:') && document.body.innerText.includes('Подключения:')")
# Track only list requests; all other requests use the real server.
js("window.stateFetch=window.fetch;window.stateCalls=0;window.stateMode='real';window.fetch=(url,options)=>{if(String(url).includes('/api/devices?')){window.stateCalls++;if(window.stateMode==='error')return Promise.reject(new Error('offline'));if(window.stateMode==='empty')return Promise.resolve(new Response(JSON.stringify({devices:[],serverTime:new Date().toISOString()}),{headers:{'Content-Type':'application/json'}}));}return window.stateFetch(url,options)};window.stateMode='error';return true;")
wait("document.body.innerText.includes('Не удалось обновить состояние')", 'inline API failure', 10)
assert js("return document.body.innerText.includes('Последнее обновление:')")
js("window.stateMode='real';return true;")
click('Повторить')
wait("!document.body.innerText.includes('Не удалось обновить состояние')", 'state recovery')
js("window.stateMode='empty';return true;")
wait("document.body.innerText.includes('Устройства этой игры пока не зарегистрированы')", 'empty devices', 10)
assert js("const tab=[...document.querySelectorAll('.n-tabs-tab[data-name]')].find(t=>t.textContent.trim()==='Игра');tab.click();return true;")
wait("location.search.includes('tab=game')", 'leave state tab')
calls = js('return window.stateCalls')
time.sleep(6)
assert js('return window.stateCalls') == calls, 'Inactive state still polls'
js('window.fetch=window.stateFetch;return true;')
# Create a separate developer in each disposable database.
bootstrap = (ROOT / 'evening_detective_server/scripts/dev-user.sql').read_text()
for mode in ('venue', 'prod'):
run('docker', 'exec', '-i', stand.name + '-' + mode + '-pg', 'psql', '-U', 'test', '-d', 'test', '-v', 'ON_ERROR_STOP=1', '-v', 'dev_username=TEST developer', '-v', 'dev_email=developer@test.invalid', '-v', 'dev_password=test-only-password', input=bootstrap)
stand.sql(mode, "UPDATE users SET roles='[\"developer\"]'::jsonb WHERE email='developer@test.invalid';")
api = API(stand.apis[mode].base)
api.token = api.request('/api/auth/login', {'email':'developer@test.invalid','password':'test-only-password'})['accessToken']
if mode == 'prod':
api.request('/api/games', expected=403)
api.request(f'/api/games/{game}', expected=403)
else:
summary = api.request(f'/api/games/{game}')['game']
public_fields = {'ID','name','description','format','location','startAt','status'}
# Gateway may emit unset protobuf fields as null/empty collections.
assert all(not value for key, value in summary.items() if key not in public_fields)
games = api.request('/api/games')['games']
assert games and all(not value for g in games for key, value in g.items() if key not in public_fields)
old_connections = {c['ID'] for d in stand.apis['venue'].request('/api/devices')['devices'] for c in d['connections']}
login('venue', 'developer@test.invalid')
navigate('/games')
wait("document.body.innerText.includes('Импорт игры')", 'developer games')
assert js("return !document.body.innerText.includes('Создать игру') && !document.body.innerText.includes('Результаты')")
first_request = len(stand.request_paths)
navigate(f'/games/{game}?tab=answers')
wait("location.search.includes('tab=state') && document.body.innerText.includes('Последнее обновление:')", 'developer state default')
assert js("return [...document.querySelectorAll('.n-tabs-tab[data-name]')].map(t=>t.textContent.trim()).join(',')==='Состояние'")
time.sleep(6)
assert not any(mode == 'venue' and path.startswith(f'/api/games/{game}/') for mode, path in stand.request_paths[first_request:]), 'Developer loads private game data'
# Heartbeat is browser-only for this role.
device_id = js("return localStorage.getItem('evening-detective-device-ID')")
devices = stand.apis['venue'].request('/api/devices')['devices']
current_connections = [c for d in devices if d['ID'] == device_id for c in d['connections'] if c['ID'] not in old_connections]
assert current_connections and all(c['kind'] == 'browser' for c in current_connections)
login('prod', 'developer@test.invalid')
navigate(f'/games/{game}?tab=state')
time.sleep(1)
assert js("return ![...document.querySelectorAll('.n-tabs-tab[data-name]')].some(t=>t.textContent.trim()==='Состояние')")
login('venue')
print('PASS: stage 13 Firefox state counts, inline failure/recovery/empty, inactive polling, developer restricted games/default tab/browser context and prod denial', flush=True)
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')
wait("[...document.querySelectorAll('button')].some(b=>b.textContent.trim()==='Подготовить пакет' && !b.disabled)", 'export button')
click('Подготовить пакет')
wait("document.body.innerText.includes('Скачать пакет')", 'export ready', 60)
previous_downloads = {p.name for p in downloads.glob('*.zip')}
@@ -193,6 +260,7 @@ def check_browser_flow(stand, fixtures):
wait(f"location.pathname==='/games/{game}'", 'open imported game')
if width == 1440:
check_presence(data)
check_state(data)
navigate(f'/games/{game}?tab=payment')
wait("document.body.innerText.includes('Проверить билет')", 'ticket checking')
click('Проверить билет')
@@ -246,6 +314,7 @@ def check_browser_flow(stand, fixtures):
navigate(f'/games/{game}?tab=game')
wait("document.querySelector('[aria-label=\"Завершить игру\"]')!==null", 'finish control')
click('Завершить игру')
first_request = len(stand.request_paths)
navigate(f'/games/{game}?tab=answers')
wait("document.querySelector('.answers-score-input input')!==null", 'grading form')
input_value('.answers-score-input input', '5')
@@ -293,6 +362,7 @@ def check_browser_flow(stand, fixtures):
assert stand.apis['prod'].request('/api' + urllib.parse.urlparse(data['tickets'][1]['URL']).path)['ticket']['status'] == 'used'
assert stand.apis['prod'].request(f'/api/games/{game}/results')['teams'][0]['totalScore'] == 5
login('venue')
first_request = len(stand.request_paths)
navigate(f'/games/{game}?tab=answers')
wait("document.querySelector('.answers-score-input input')!==null", 'corrected grading')
input_value('.answers-score-input input', '0')