This commit is contained in:
2026-10-13 14:51:45 +07:00
parent c7ce0195ba
commit 2e55e3c88f
+226
View File
@@ -0,0 +1,226 @@
"""Firefox regression: python3 tests/venue/move_feedback.py /path/to/frontend/dist."""
import json
import mimetypes
import pathlib
import socket
import subprocess
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
INJECT = r"""<script>
localStorage.setItem('accessToken', 'test-token');
window.places = [];
window.actionCalls = 0;
window.storyCalls = 0;
window.heldStories = [];
window.errors = [];
window.addEventListener('unhandledrejection', e => errors.push(String(e.reason)));
const originalFetch = window.fetch.bind(window);
const jsonResponse = (data, status = 200) => new Response(JSON.stringify(data), {
status, headers: {'Content-Type': 'application/json'}
});
window.makePlace = code => ({code, name: 'Новый ход ' + code, text: 'Длинный текст.\n'.repeat(150)});
window.fetch = async (url, options) => {
const path = new URL(url, location.href).pathname;
if (!path.startsWith('/api/')) return originalFetch(url, options);
if (path === '/api/users/me') return jsonResponse({user: {ID: 1, roles: ['author']}});
if (path.endsWith('/story')) {
storyCalls++;
if (window.failStory) return jsonResponse({code: 13, message: 'Тест ошибки загрузки'}, 500);
const result = {
game: {ID: 1, status: 'run', format: 'online'},
story: {introduction: {text: 'Длинное вступление.\n'.repeat(150)}, places: structuredClone(places)},
actions: places.map(p => ({code: p.code}))
};
if (window.holdStory) return new Promise(resolve => heldStories.push(() => resolve(jsonResponse(result))));
return jsonResponse(result);
}
if (path.endsWith('/answers')) return jsonResponse({answers: []});
if (path.endsWith('/actions')) {
actionCalls++;
const code = JSON.parse(options.body).code;
return new Promise(resolve => {
window.finishAction = (fail = false) => {
if (fail) resolve(jsonResponse({code: 13, message: 'Не удалось сделать ход'}, 500));
else { places.push(makePlace(code)); resolve(jsonResponse({})); }
};
});
}
return jsonResponse({});
};
</script>"""
def main(dist):
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
path = dist / urlparse(self.path).path.lstrip('/')
if path.is_file():
body = path.read_bytes()
mime = mimetypes.guess_type(str(path))[0] or 'application/octet-stream'
else:
body = (dist / 'index.html').read_text().replace('<head>', '<head>' + INJECT).encode()
mime = 'text/html'
self.send_response(200)
self.send_header('Content-Type', mime)
self.end_headers()
self.wfile.write(body)
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
with tempfile.TemporaryDirectory(prefix='move-feedback-') as directory:
profile = pathlib.Path(directory)
with socket.socket() as reservation:
reservation.bind(('127.0.0.1', 0))
port = reservation.getsockname()[1]
(profile / 'user.js').write_text(f'user_pref("marionette.port", {port});\n')
with (profile / 'firefox.log').open('w') as log:
firefox = subprocess.Popen(['firefox', '--headless', '--no-remote', '--profile', directory,
'--marionette', 'about:blank'], stdout=log, stderr=log)
sock = None
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 did not start: ' + (profile / 'firefox.log').read_text()
sock.settimeout(30)
def read_message():
size = b''
while not size.endswith(b':'):
part = sock.recv(1)
assert part, 'Firefox disconnected'
size += part
remaining, data = int(size[:-1]), 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()
assert not result[2], result[2]
return result[3]
def js(script):
result = command('WebDriver:ExecuteScript', {'script': script, 'args': [],
'newSandbox': False, 'sandbox': None})
return result.get('value') if isinstance(result, dict) and 'value' in result else result
def wait(expression):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if js('return ' + expression):
return
time.sleep(.1)
raise AssertionError(expression + ': ' + str(js('return document.body.innerText'))[:1000])
def submit(code):
js("const input=document.querySelector('#run');input.value=" + json.dumps(code) +
";input.dispatchEvent(new Event('input',{bubbles:true}));")
wait("!document.querySelector('.game-button-run').disabled")
js("for(let i=0;i<5;i++)document.querySelector('.game-button-run').click();")
wait("document.querySelector('.game-button-run').textContent.includes('Отправляем')")
def check_position(count):
wait(f"document.querySelectorAll('.story-place').length === {count}")
wait("document.querySelector('#run').value === ''")
assert js("const c=document.querySelector('.center-block-custom');"
"const p=[...document.querySelectorAll('.story-place')].at(-1);"
"return Math.abs(p.getBoundingClientRect().top-c.getBoundingClientRect().top)<2;")
command('WebDriver:NewSession', {'capabilities': {'alwaysMatch': {}}})
command('WebDriver:Navigate', {'url': f'http://127.0.0.1:{server.server_port}/team-story/test?password=test'})
wait("document.querySelector('#run') && !document.querySelector('#run').disabled")
js('window.holdStory=true;')
wait('heldStories.length === 1')
submit('p1')
assert js('return actionCalls') == 1
js('window.holdStory=false;finishAction();')
check_position(1)
js('heldStories.shift()();')
time.sleep(.2)
assert js("return document.querySelectorAll('.story-place').length") == 1
js("document.querySelector('.center-block-custom').scrollTo({top:0,behavior:'instant'});")
submit('p2')
assert js('return actionCalls') == 2
js('window.holdStory=true;finishAction();')
wait('heldStories.length === 1')
time.sleep(2.2)
assert js('return heldStories.length') == 1
assert js("return document.querySelector('.game-button-run').disabled")
js('window.holdStory=false;heldStories.shift()();')
check_position(2)
sender = command('WebDriver:GetWindowHandle')
if isinstance(sender, dict):
sender = sender['value']
teammate = command('WebDriver:NewWindow', {'type': 'tab'})['handle']
command('WebDriver:SwitchToWindow', {'handle': teammate})
command('WebDriver:Navigate', {'url': f'http://127.0.0.1:{server.server_port}/team-story/test?password=test'})
wait("document.querySelector('#run') && !document.querySelector('#run').disabled")
js("places.push(makePlace('p1'),makePlace('p2'));")
wait("document.querySelectorAll('.story-place').length === 2")
command('WebDriver:SwitchToWindow', {'handle': sender})
submit('bad')
js('finishAction(true);')
wait("!document.querySelector('#run').disabled")
assert js("return document.querySelector('#run').value") == 'bad'
submit('p3')
js('window.failStory=true;finishAction();')
wait("document.querySelector('#run').value === '' && document.body.innerText.includes('Ход сохранён')")
assert js('return actionCalls') == 4
js('window.failStory=false;')
wait("document.querySelectorAll('.story-place').length === 3")
# A teammate's move arrives through polling while this player reads old text.
js("document.querySelector('.center-block-custom').scrollTo({top:0,behavior:'instant'});"
"places.push(makePlace('remote'));")
wait("document.querySelectorAll('.story-place').length === 4")
assert js("return document.querySelector('.center-block-custom').scrollTop") == 0
assert js('return errors') == []
command('WebDriver:SwitchToWindow', {'handle': teammate})
js("document.querySelector('.center-block-custom').scrollTo({top:0,behavior:'instant'});"
"places.push(makePlace('p3'),makePlace('remote'));")
wait("document.querySelectorAll('.story-place').length === 4")
assert js("return document.querySelector('.center-block-custom').scrollTop") == 0
assert js('return errors') == []
print('PASS: first/subsequent move, rapid clicks, delayed refresh, stale polling, both errors, remote move preserves reading position')
finally:
if sock:
sock.close()
firefox.terminate()
try:
firefox.wait(timeout=5)
except subprocess.TimeoutExpired:
firefox.kill()
firefox.wait()
server.shutdown()
if __name__ == '__main__':
main(pathlib.Path(sys.argv[1]).resolve())