Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adjust end to end tests to support websocket infrastructure #987

Merged
merged 3 commits into from
Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,10 @@ For further information on available command line arguments run `pytest --help`
or see the official
[pytest documentation](https://docs.pytest.org/en/latest/usage.html).

There are also some integration tests which simulate real traffic to the test
There are also some end to end tests which simulate real traffic to the test
server.
```
$ pipenv run integration
$ pipenv run e2e
```

Some of them may fail depending on the configuration deployed on the test
Expand Down
3 changes: 2 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[scripts]
devserver = "python main.py --configuration-file dev-config.yml"
tests = "py.test --doctest-modules --doctest-continue-on-failure --cov-report=term-missing --cov-branch --cov=server --mysql_database=faf -o testpaths=tests -m 'not rabbitmq'"
integration = "py.test -o testpaths=integration_tests"
e2e = "py.test -o testpaths=e2e_tests"
vulture = "vulture main.py server/ --sort-by-size"
doc = "pdoc3 --html --force server"

Expand Down Expand Up @@ -36,6 +36,7 @@ pytest-asyncio = "*"
pytest-cov = "*"
pytest-mock = "*"
vulture = "*"
websockets = "*"

[requires]
python_version = "3.10"
542 changes: 265 additions & 277 deletions Pipfile.lock

Large diffs are not rendered by default.

File renamed without changes.
4 changes: 2 additions & 2 deletions integration_tests/conftest.py → e2e_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ class Manager():
def __init__(self):
self.clients = []

async def add_client(self, host="test.faforever.com", port=8001):
async def add_client(self, uri="wss://ws.faforever.xyz"):
client = FAFClient()
await client.connect(host, port)
await client.ws_connect(uri)
self.clients.append(client)
return client

Expand Down
31 changes: 23 additions & 8 deletions integration_tests/fafclient.py → e2e_tests/fafclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
import subprocess
from hashlib import sha256

from server.protocol import QDataStreamProtocol
from websockets.client import connect as ws_connect

from server.protocol import QDataStreamProtocol, SimpleJsonProtocol
from tests.integration_tests.conftest import read_until, read_until_command

from .websocket_protocol import WebsocketProtocol


class FAFClient(object):
"""docstring for FAFClient."""
class FAFClient:
"""Simulates a FAF client."""

def __init__(self, user_agent="faf-client", version="1.0.0-dev"):
self.proto = None
Expand All @@ -28,11 +32,15 @@ async def close(self):

await self.proto.close()

async def connect(self, host, port):
self.proto = QDataStreamProtocol(
async def connect(self, host, port, protocol_class=QDataStreamProtocol):
self.proto = protocol_class(
*(await asyncio.open_connection(host, port))
)

async def ws_connect(self, uri, protocol_class=SimpleJsonProtocol):
websocket = await ws_connect(uri, open_timeout=60)
self.proto = WebsocketProtocol(websocket, protocol_class)

async def send_message(self, message):
"""Send a message to the server"""
if not self.is_connected():
Expand All @@ -50,8 +58,13 @@ async def read_until(self, predicate, timeout=5):
timeout=timeout
)

async def read_until_command(self, command, timeout=5):
return await read_until_command(self.proto, command, timeout=timeout)
async def read_until_command(self, command, timeout=5, **kwargs):
return await read_until_command(
self.proto,
command,
timeout=timeout,
**kwargs,
)

async def read_until_game_launch(self, uid):
return await self.read_until(
Expand Down Expand Up @@ -116,12 +129,14 @@ async def host_game(self, **kwargs):
game_id = int(msg["uid"])

await self.open_fa()
await self.read_until_command("HostGame", target="game")
return game_id

async def join_game(self, game_id, **kwargs):
await self.send_message({
"command": "game_join",
"uid": game_id
"uid": game_id,
**kwargs
})
await self.read_until_command("game_launch")

Expand Down
6 changes: 4 additions & 2 deletions integration_tests/test_game.py → e2e_tests/test_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ async def test_custom_game_1v1_game_stats(client_factory, json_stats_1v1):
for client in (client1, client2):
await client.send_gpg_command("GameState", "Ended")

await client1.read_until_command("updated_achievements", timeout=10)
await client2.read_until_command("updated_achievements", timeout=2)
# The client no longer gets a `updated_achievements` notification, but we
# keep the test in case it could generate an exception on the server side.
await client1.get_player_ratings("test", "test2", timeout=10)
await client2.get_player_ratings("test", "test2", timeout=2)


async def test_custom_game_1v1_extra_gameresults(client_factory):
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

from .test_game import simulate_result_reports

# NOTE: Tests will cause matchmaker violations, so after running them a few
# times, they may start to fail.


async def test_ladder_1v1_match(client_factory):
"""More or less the same as the regression test version"""
Expand Down
File renamed without changes.
87 changes: 87 additions & 0 deletions e2e_tests/websocket_protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import asyncio
import contextlib
from unittest import mock

import websockets

from server.protocol import DisconnectedError, Protocol


class WebsocketProtocol:
def __init__(
self,
websocket: websockets.client.WebSocketClientProtocol,
protocol_class: type[Protocol],
):
self.websocket = websocket
reader = asyncio.StreamReader()
reader.set_transport(asyncio.ReadTransport())
self.proto = protocol_class(
reader,
mock.create_autospec(asyncio.StreamWriter)
)

def is_connected(self) -> bool:
"""
Return whether or not the connection is still alive
"""
return self.websocket.open

async def read_message(self) -> dict:
if self.proto.reader._buffer:
# If buffer contains partial data, this await call could hang.
return await self.proto.read_message()

msg = await self.websocket.recv()
self.proto.reader.feed_data(msg)
# msg should always contain at least 1 complete message.
# If it contains partial data, this await call could hang.
return await self.proto.read_message()

async def send_message(self, message: dict) -> None:
"""
Send a single message in the form of a dictionary

# Errors
May raise `DisconnectedError`.
"""
await self.send_raw(self.proto.encode_message(message))

async def send_messages(self, messages: list[dict]) -> None:
"""
Send multiple messages in the form of a list of dictionaries.

# Errors
May raise `DisconnectedError`.
"""
for message in messages:
await self.send_message(message)

async def send_raw(self, data: bytes) -> None:
"""
Send raw bytes. Should generally not be used.

# Errors
May raise `DisconnectedError`.
"""
try:
await self.websocket.send(data)
except websockets.exceptions.ConnectionClosedOK:
raise DisconnectedError("The websocket connection was closed")
except websockets.exceptions.ConnectionClosed as e:
raise DisconnectedError("Websocket connection lost!") from e

def abort(self) -> None:
# SelectorTransport only
self.websocket.transport.abort()

async def close(self) -> None:
"""
Close the websocket connection.

# Errors
Never raises. Any exceptions that occur while waiting to close are
ignored.
"""
with contextlib.suppress(Exception):
await self.websocket.close()
8 changes: 8 additions & 0 deletions server/protocol/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ def encode_message(message: dict) -> bytes:
"""
pass # pragma: no cover

@staticmethod
@abstractmethod
def decode_message(data: bytes) -> dict:
"""
Decode a message from raw bytes.
"""
pass # pragma: no cover

def is_connected(self) -> bool:
"""
Return whether or not the connection is still alive
Expand Down
40 changes: 22 additions & 18 deletions server/protocol/qdatastream.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,29 +80,15 @@ def encode_message(message: dict) -> bytes:

return QDataStreamProtocol.pack_message(json_encoder.encode(message))

async def read_message(self):
"""
Read a message from the stream

# Errors
Raises `IncompleteReadError` on malformed stream.
"""
try:
length, *_ = struct.unpack("!I", await self.reader.readexactly(4))
block = await self.reader.readexactly(length)
except IncompleteReadError as e:
if self.reader.at_eof() and not e.partial:
raise DisconnectedError()
# Otherwise reraise
raise

pos, action = self.read_qstring(block)
@staticmethod
def decode_message(data: bytes) -> dict:
_, action = QDataStreamProtocol.read_qstring(data)
if action in ("PING", "PONG"):
return {"command": action.lower()}

message = json.loads(action)
try:
for part in self.read_block(block):
for part in QDataStreamProtocol.read_block(data):
try:
message_part = json.loads(part)
if part != action:
Expand All @@ -115,6 +101,24 @@ async def read_message(self):
pass
return message

async def read_message(self):
"""
Read a message from the stream

# Errors
Raises `IncompleteReadError` on malformed stream.
"""
try:
length, *_ = struct.unpack("!I", await self.reader.readexactly(4))
block = await self.reader.readexactly(length)
except IncompleteReadError as e:
if self.reader.at_eof() and not e.partial:
raise DisconnectedError()
# Otherwise reraise
raise

return QDataStreamProtocol.decode_message(block)


PING_MSG = QDataStreamProtocol.pack_message("PING")
PONG_MSG = QDataStreamProtocol.pack_message("PONG")
6 changes: 5 additions & 1 deletion server/protocol/simple_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ class SimpleJsonProtocol(Protocol):
def encode_message(message: dict) -> bytes:
return (json_encoder.encode(message) + "\n").encode()

@staticmethod
def decode_message(data: bytes) -> dict:
return json.loads(data.strip())

async def read_message(self) -> dict:
line = await self.reader.readline()
if not line:
raise DisconnectedError()
return json.loads(line.strip())
return SimpleJsonProtocol.decode_message(line)
4 changes: 4 additions & 0 deletions tests/integration_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,10 @@ async def consume(self):
def encode_message(message: dict) -> bytes:
raise NotImplementedError("AioQueueProtocol is read-only")

@staticmethod
def decode_message(data: bytes) -> dict:
raise NotImplementedError("AioQueueProtocol doesn't user bytes")

async def read_message(self) -> dict:
return await self.aio_queue.get()

Expand Down