This commit is contained in:
2026-10-11 03:05:52 +07:00
parent 7fe5525a01
commit 5973eaf072
5 changed files with 684 additions and 4 deletions
+210
View File
@@ -0,0 +1,210 @@
"""Firefox UI checks against the disposable real servers started by flow.py."""
import json
import socket
import subprocess
import time
import urllib.parse
def check_browser_flow(stand, fixtures):
profile = stand.directory / 'firefox'
profile.mkdir()
downloads = stand.directory / 'downloads'
downloads.mkdir()
with socket.socket() as reservation:
reservation.bind(('127.0.0.1', 0))
port = reservation.getsockname()[1]
(profile / 'user.js').write_text('\n'.join([
f'user_pref("marionette.port", {port});',
'user_pref("browser.shell.checkDefaultBrowser", false);',
'user_pref("browser.tabs.warnOnClose", false);',
'user_pref("browser.download.folderList", 2);',
f'user_pref("browser.download.dir", {json.dumps(str(downloads))});',
'user_pref("browser.download.useDownloadDir", true);',
'user_pref("browser.helperApps.neverAsk.saveToDisk", "application/zip,application/octet-stream");',
'user_pref("browser.download.always_ask_before_handling_new_types", false);',
]))
sock = None
with (stand.directory / 'firefox.log').open('w') as log:
firefox = subprocess.Popen(['firefox', '--headless', '--no-remote', '--profile', str(profile), '--marionette', '--remote-allow-system-access', 'about:blank'], stdout=log, stderr=log)
try:
deadline = time.monotonic() + 15
while sock is None and time.monotonic() < deadline:
try:
sock = socket.create_connection(('127.0.0.1', port), timeout=1)
except OSError:
time.sleep(.1)
assert sock is not None, 'Firefox Marionette did not start'
sock.settimeout(30)
def read_message():
size = b''
while not size.endswith(b':'):
part = sock.recv(1)
assert part, 'Firefox disconnected'
size += part
remaining = int(size[:-1])
data = b''
while remaining:
part = sock.recv(remaining)
assert part, 'Firefox disconnected'
data += part
remaining -= len(part)
return json.loads(data)
read_message()
command_id = 0
def command(name, args=None):
nonlocal command_id
command_id += 1
payload = json.dumps([0, command_id, name, args or {}]).encode()
sock.sendall(str(len(payload)).encode() + b':' + payload)
result = read_message()
if result[2]:
raise RuntimeError('Firefox command failed: ' + name)
return result[3]
def js(script, *args):
result = command('WebDriver:ExecuteScript', {'script': script, 'args': list(args), 'newSandbox': False, 'sandbox': None})
return result.get('value') if isinstance(result, dict) and 'value' in result else result
def wait(script, description, seconds=15):
until = time.monotonic() + seconds
while time.monotonic() < until:
if js('return ' + script):
return
time.sleep(.1)
raise AssertionError('Browser timed out: ' + description)
def navigate(path):
command('WebDriver:Navigate', {'url': stand.public + path})
def click(label):
assert js("const button=[...document.querySelectorAll('button')].find(b=>b.textContent.trim()===arguments[0] || b.getAttribute('aria-label')===arguments[0]);if(!button || button.disabled)return false;button.click();return true;", label), 'Missing or disabled button: ' + label
def input_value(selector, value):
assert js("const input=document.querySelector(arguments[0]);if(!input)return false;input.value=arguments[1];input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));return true;", selector, value), 'Missing input: ' + selector
def login(mode):
stand.current = mode
navigate('/login')
js('localStorage.clear();return true;')
navigate('/login')
wait("document.querySelector('input[type=password]')!==null", 'login form')
input_value('input[placeholder="detective@mail.ru"]', 'organizer@test.invalid')
input_value('input[type=password]', 'test-only-password')
click('Вход')
wait("location.pathname==='/office'", 'login')
command('WebDriver:NewSession', {'capabilities': {'alwaysMatch': {'acceptInsecureCerts': True}}})
for width, data in zip((1440, 390), fixtures, strict=True):
game, captain = data['game'], data['teams'][0]
command('WebDriver:SetWindowRect', {'width': width, 'height': 900})
login('prod')
navigate(f'/games/{game}?tab=settings')
wait("document.body.innerText.includes('Подготовить пакет')", 'export button')
click('Подготовить пакет')
wait("document.body.innerText.includes('Скачать пакет')", 'export ready', 60)
previous_downloads = {p.name for p in downloads.glob('*.zip')}
click('Скачать пакет')
until = time.monotonic() + 15
package = None
while time.monotonic() < until:
candidates = [p for p in downloads.glob('*.zip') if p.name not in previous_downloads and p.stat().st_size and not p.with_suffix(p.suffix + '.part').exists()]
if candidates:
package = max(candidates, key=lambda p: p.stat().st_mtime)
break
time.sleep(.1)
assert package is not None, 'ZIP download missing'
raw = package.read_bytes()
assert raw.startswith(b'PK'), 'Downloaded file is not ZIP'
login('venue')
navigate('/games')
wait("document.body.innerText.includes('Импорт игры')", 'import button')
click('Импорт игры')
js("const dt=new DataTransfer();dt.items.add(new File([new Uint8Array(arguments[0])],arguments[1],{type:'application/zip'}));const input=document.querySelector('input[type=file]');input.files=dt.files;input.dispatchEvent(new Event('change',{bubbles:true}));return true;", list(raw), package.name)
click('Загрузить пакет')
wait("document.body.innerText.includes('Игра импортирована')", 'real ZIP import')
assert js("const r=document.querySelector('[role=dialog]').getBoundingClientRect();return r.left>=0 && r.right<=innerWidth"), 'Import modal overflows viewport'
command('WebDriver:PerformActions', {'actions': [{'type': 'key', 'id': 'keyboard', 'actions': [{'type': 'keyDown', 'value': '\ue004'}, {'type': 'keyUp', 'value': '\ue004'}]}]})
assert js("return document.querySelector('[role=dialog]').contains(document.activeElement)"), 'Modal lost keyboard focus'
click('Открыть игру')
wait(f"location.pathname==='/games/{game}'", 'open imported game')
navigate(f'/games/{game}?tab=payment')
wait("document.body.innerText.includes('Проверить билет')", 'ticket checking')
click('Проверить билет')
input_value('input[placeholder="Или вставьте ссылку на билет"]', data['tickets'][1]['URL'])
assert js("const button=[...document.querySelectorAll('[role=dialog] button')].find(b=>b.textContent.trim()==='Проверить билет');button.click();return true;")
wait("document.body.innerText.includes('Принять билет')", 'active ticket')
click('Принять билет')
wait("document.body.innerText.includes('уже использован')", 'accepted ticket')
navigate(urllib.parse.urlparse(data['tickets'][1]['URL']).path)
wait("document.body.innerText.includes('Билет уже использован')", 'original ticket page')
navigate(f'/games/{game}?tab=game')
wait("document.querySelector('[aria-label=\"Начать игру\"]')!==null", 'start control')
click('Начать игру')
wait("document.querySelector('[aria-label=\"Поставить игру на паузу\"]')!==null", 'running game')
team_path = f'/team-story/{captain["ID"]}?' + urllib.parse.urlencode({'password': captain['password']})
navigate(team_path)
wait("document.querySelector('#run')!==null && !document.querySelector('#run').disabled", 'team input')
stand.delay_actions = .4
input_value('#run', 'p1')
click('Поехали')
assert js("return document.querySelector('.game-button-run').disabled"), 'Move submission is not blocked'
js("document.querySelector('.game-button-run').click();return true;")
wait("document.body.innerText.includes('TEST Place p1')", 'first move')
assert len(stand.apis['venue'].story(captain)['actions']) == 1, 'Double submission created duplicate moves'
stand.sql('venue', f"UPDATE teams SET password='revoked' WHERE id={int(captain['ID'])};")
input_value('#run', 'p2')
click('Поехали')
wait("document.body.innerText.includes('команда не найдена')", 'refused move error')
assert js("return document.querySelector('#run').value==='p2'"), 'Refused move erased the input'
escaped_password = captain['password'].replace("'", "''")
stand.sql('venue', f"UPDATE teams SET password='{escaped_password}' WHERE id={int(captain['ID'])};")
stand.delay_actions = 0
stand.apis['venue'].request(f'/api/games/{game}/pause', {}, 'PUT')
wait("document.querySelector('#run').disabled", 'pause polling')
assert js("return document.querySelector('.game-button-run').disabled"), 'Prefilled move is still enabled during pause'
stand.apis['venue'].request(f'/api/games/{game}/play', {}, 'PUT')
wait("!document.querySelector('.game-button-run').disabled", 'resume polling')
click('Поехали')
wait("document.body.innerText.includes('TEST Place p2')", 'second move')
if data['format'] == 'online':
input_value('.questions-block textarea', 'Butler')
click('Сохранить ответы')
wait("document.body.innerText.includes('Ответы сохранены')", 'answer submission')
else:
assert js("return document.querySelector('.questions-block')===null"), 'Offline game exposes digital answers'
navigate(f'/games/{game}?tab=teams')
wait("document.body.innerText.includes('Выдать: TEST Clue p1')", 'pending clue')
click('Выдать: TEST Clue p1')
wait("document.querySelector('[role=dialog]')!==null", 'clue dialog')
click('Выдать')
navigate(f'/games/{game}?tab=game')
wait("document.querySelector('[aria-label=\"Завершить игру\"]')!==null", 'finish control')
click('Завершить игру')
navigate(f'/games/{game}?tab=answers')
wait("document.querySelector('.answers-score-input input')!==null", 'grading form')
input_value('.answers-score-input input', '5')
click('Сохранить баллы')
wait("document.body.innerText.includes('Баллы сохранены')", 'saved scores')
navigate(f'/games/{game}/results')
wait("document.body.innerText.includes('TEST Captain')", 'results')
navigate(team_path)
wait("document.body.innerText.includes('Игра завершена')", 'finished player view')
assert js("return document.querySelector('.questions-block')===null"), 'Finished game exposes questions'
navigate(f'/team-story/{captain["ID"]}?password=wrong')
wait("document.body.innerText.includes('Не удалось загрузить дело')", 'wrong-password error')
print(f'PASS: real Firefox {width}px {data["format"]}: export/download/import, tickets, moves, pause, clues, grading/results, errors and keyboard', flush=True)
command('Marionette:Quit', {'flags': ['eForceQuit']})
finally:
if sock:
sock.close()
firefox.terminate()
try:
firefox.wait(timeout=5)
except subprocess.TimeoutExpired:
firefox.kill()
firefox.wait(timeout=5)