up
This commit is contained in:
@@ -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())
|
||||||
Reference in New Issue
Block a user