Skip to content

Commit

Permalink
🎨 [e2e] Start from template playwright test (#6225)
Browse files Browse the repository at this point in the history
Co-authored-by: sanderegg <[email protected]>
  • Loading branch information
odeimaiz and sanderegg authored Aug 27, 2024
1 parent 2e2993c commit f585573
Show file tree
Hide file tree
Showing 5 changed files with 302 additions and 156 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import datetime
import logging
import re
from dataclasses import dataclass
from typing import Dict, Final, Union

import arrow
from playwright.sync_api import FrameLocator, Page, WebSocket, expect

from .logging_tools import log_context
from .playwright import (
SECOND,
MINUTE,
SOCKETIO_MESSAGE_PREFIX,
SocketIOEvent,
decode_socketio_42_message,
wait_for_service_running,
)

_S4L_STREAMING_ESTABLISHMENT_MAX_TIME: Final[int] = 15 * SECOND
_S4L_SOCKETIO_REGEX: Final[re.Pattern] = re.compile(
r"^(?P<protocol>[^:]+)://(?P<node_id>[^\.]+)\.services\.(?P<hostname>[^\/]+)\/socket\.io\/.+$"
)
_EC2_STARTUP_MAX_WAIT_TIME: Final[int] = 1 * MINUTE
_S4L_MAX_STARTUP_TIME: Final[int] = 1 * MINUTE
_S4L_DOCKER_PULLING_MAX_TIME: Final[int] = 10 * MINUTE
_S4L_AUTOSCALED_MAX_STARTUP_TIME: Final[int] = (
_EC2_STARTUP_MAX_WAIT_TIME + _S4L_DOCKER_PULLING_MAX_TIME + _S4L_MAX_STARTUP_TIME
)
_S4L_STARTUP_SCREEN_MAX_TIME: Final[int] = 45 * SECOND


@dataclass(kw_only=True)
class S4LWaitForWebsocket:
logger: logging.Logger

def __call__(self, new_websocket: WebSocket) -> bool:
if re.match(_S4L_SOCKETIO_REGEX, new_websocket.url):
self.logger.info("found S4L websocket!")
return True

return False


@dataclass(kw_only=True)
class _S4LSocketIOCheckBitRateIncreasesMessagePrinter:
observation_time: datetime.timedelta
logger: logging.Logger
_initial_bit_rate: float = 0
_initial_bit_rate_time: datetime.datetime = arrow.utcnow().datetime

def __call__(self, message: str) -> bool:
if message.startswith(SOCKETIO_MESSAGE_PREFIX):
decoded_message: SocketIOEvent = decode_socketio_42_message(message)
if (
decoded_message.name == "server.video_stream.bitrate_data"
and "bitrate" in decoded_message.obj
):
current_bitrate = decoded_message.obj["bitrate"]
if self._initial_bit_rate == 0:
self._initial_bit_rate = current_bitrate
self._initial_bit_rate_time = arrow.utcnow().datetime
self.logger.info(
"%s",
f"{self._initial_bit_rate=} at {self._initial_bit_rate_time.isoformat()}",
)
return False

# NOTE: MaG says the value might also go down, but it shall definitely change,
# if this code proves unsafe we should change it.
elapsed_time = arrow.utcnow().datetime - self._initial_bit_rate_time
if (
elapsed_time > self.observation_time
and "bitrate" in decoded_message.obj
):
current_bitrate = decoded_message.obj["bitrate"]
bitrate_test = bool(self._initial_bit_rate != current_bitrate)
self.logger.info(
"%s",
f"{current_bitrate=} after {elapsed_time=}: {'good!' if bitrate_test else 'failed! bitrate did not change! TIP: talk with MaG about underwater cables!'}",
)
return bitrate_test

return False


def launch_S4L(page: Page, node_id, log_in_and_out: WebSocket, autoscaled: bool) -> Dict[str, Union[WebSocket, FrameLocator]]:
with log_context(logging.INFO, "launch S4L") as ctx:
predicate = S4LWaitForWebsocket(logger=ctx.logger)
with page.expect_websocket(
predicate,
timeout=_S4L_STARTUP_SCREEN_MAX_TIME
+ (
_S4L_AUTOSCALED_MAX_STARTUP_TIME
if autoscaled
else _S4L_MAX_STARTUP_TIME
)
+ 10 * SECOND,
) as ws_info:
s4l_iframe = wait_for_service_running(
page=page,
node_id=node_id,
websocket=log_in_and_out,
timeout=(
_S4L_AUTOSCALED_MAX_STARTUP_TIME
if autoscaled
else _S4L_MAX_STARTUP_TIME
),
press_start_button=False,
)
s4l_websocket = ws_info.value
ctx.logger.info("acquired S4L websocket!")
return {
"websocket": s4l_websocket,
"iframe" : s4l_iframe,
}


def interact_with_S4L(page: Page, s4l_iframe: FrameLocator) -> None:
# Wait until grid is shown
# NOTE: the startup screen should disappear very fast after the websocket was acquired
with log_context(logging.INFO, "Interact with S4l"):
s4l_iframe.get_by_test_id("tree-item-Grid").nth(0).click()
page.wait_for_timeout(3000)


def check_video_streaming(page: Page, s4l_iframe: FrameLocator, s4l_websocket: WebSocket) -> None:
with log_context(logging.INFO, "Check videostreaming works") as ctx:
waiter = _S4LSocketIOCheckBitRateIncreasesMessagePrinter(
observation_time=datetime.timedelta(
milliseconds=_S4L_STREAMING_ESTABLISHMENT_MAX_TIME / 2.0,
),
logger=ctx.logger,
)
with s4l_websocket.expect_event(
"framereceived",
waiter,
timeout=_S4L_STREAMING_ESTABLISHMENT_MAX_TIME,
):
...

expect(
s4l_iframe.locator("video"),
"videostreaming is not established. "
"TIP: if using playwright integrated open source chromIUM, "
"webkit or firefox this is expected, switch to chrome/msedge!!",
).to_be_visible()
s4l_iframe.locator("video").click()
page.wait_for_timeout(3000)
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,8 @@ qx.Class.define("osparc.dashboard.CardBase", {
},

__applyUuid: function(value, old) {
osparc.utils.Utils.setIdToWidget(this, "studyBrowserListItem_"+value);
const resourceType = this.getResourceType() || "study";
osparc.utils.Utils.setIdToWidget(this, resourceType + "BrowserListItem_" + value);

this.setCardKey(value);
},
Expand Down
98 changes: 93 additions & 5 deletions tests/e2e-playwright/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import os
import random
import re
import urllib.parse
from collections.abc import Callable, Iterator
from contextlib import ExitStack
from typing import Any, Final
Expand All @@ -24,6 +25,7 @@
from pytest import Item
from pytest_simcore.helpers.logging_tools import log_context
from pytest_simcore.helpers.playwright import (
SECOND,
MINUTE,
AutoRegisteredUser,
RunningState,
Expand All @@ -35,6 +37,8 @@
)

_PROJECT_CLOSING_TIMEOUT: Final[int] = 10 * MINUTE
_OPENING_NEW_EMPTY_PROJECT_MAX_WAIT_TIME: Final[int] = 30 * SECOND
_OPENING_TUTORIAL_MAX_WAIT_TIME: Final[int] = 3 * MINUTE


def pytest_addoption(parser: pytest.Parser) -> None:
Expand Down Expand Up @@ -94,6 +98,13 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=None,
help="Service Key",
)
group.addoption(
"--template-id",
action="store",
type=str,
default=None,
help="Template uuid",
)
group.addoption(
"--user-agent",
action="store",
Expand Down Expand Up @@ -232,6 +243,14 @@ def service_key(request: pytest.FixtureRequest) -> str:
return os.environ["SERVICE_KEY"]


@pytest.fixture(scope="session")
def template_id(request: pytest.FixtureRequest) -> str | None:
if key := request.config.getoption("--template-id"):
assert isinstance(key, str)
return key
return None


@pytest.fixture(scope="session")
def auto_register(request: pytest.FixtureRequest) -> bool:
return bool(request.config.getoption("--autoregister"))
Expand Down Expand Up @@ -381,6 +400,7 @@ def create_new_project_and_delete(
def _(
expected_states: tuple[RunningState] = (RunningState.NOT_STARTED,),
press_open: bool = True,
template_id: str | None = None,
) -> dict[str, Any]:
assert (
len(created_project_uuids) == 0
Expand All @@ -390,15 +410,54 @@ def _(
f"Open project in {product_url=} as {product_billable=}",
) as ctx:
waiter = SocketIOProjectStateUpdatedWaiter(expected_states=expected_states)
timeout = _OPENING_TUTORIAL_MAX_WAIT_TIME if template_id is not None else _OPENING_NEW_EMPTY_PROJECT_MAX_WAIT_TIME
with (
log_in_and_out.expect_event("framereceived", waiter),
log_in_and_out.expect_event("framereceived", waiter, timeout=timeout + 10 * SECOND),
page.expect_response(
re.compile(r"/projects/[^:]+:open")
re.compile(r"/projects/[^:]+:open"),
timeout=timeout + 5 * SECOND
) as response_info,
):
# Project detail view pop-ups shows
if press_open:
page.get_by_test_id("openResource").click()
open_button = page.get_by_test_id("openResource")
if template_id is not None:
# it returns a Long Running Task
with page.expect_response(
re.compile(rf"/projects\?from_study\={template_id}")
) as lrt:
open_button.click()
lrt_data = lrt.value.json()
lrt_data = lrt_data["data"]
with log_context(
logging.INFO,
"Copying template data",
) as copying_logger:
# From the long running tasks response's urls, only their path is relevant
def url_to_path(url):
return urllib.parse.urlparse(url).path
def wait_for_done(response):
if url_to_path(response.url) == url_to_path(lrt_data["status_href"]):
resp_data = response.json()
resp_data = resp_data["data"]
assert "task_progress" in resp_data
task_progress = resp_data["task_progress"]
copying_logger.logger.info(
"task progress: %s %s",
task_progress["percent"],
task_progress["message"],
)
return False
if url_to_path(response.url) == url_to_path(lrt_data["result_href"]):
copying_logger.logger.info("project created")
return response.status == 201
return False
with page.expect_response(wait_for_done, timeout=timeout):
# if the above calls go to fast, this test could fail
# not expected in the sim4life context though
...
else:
open_button.click()
if product_billable:
# Open project with default resources
page.get_by_test_id("openWithResources").click()
Expand Down Expand Up @@ -466,6 +525,22 @@ def _(plus_button_test_id: str) -> None:
return _


@pytest.fixture
def find_and_click_template_in_dashboard(
page: Page,
) -> Callable[[str], None]:
def _(template_id: str) -> None:
with log_context(logging.INFO, f"Finding {template_id=} in dashboard"):
page.get_by_test_id("templatesTabBtn").click()
_textbox = page.get_by_test_id("searchBarFilter-textField-template")
_textbox.fill(template_id)
_textbox.press("Enter")
test_id = "templateBrowserListItem_" + template_id
page.get_by_test_id(test_id).click()

return _


@pytest.fixture
def find_and_start_service_in_dashboard(
page: Page,
Expand All @@ -478,7 +553,7 @@ def _(
_textbox = page.get_by_test_id("searchBarFilter-textField-service")
_textbox.fill(service_name)
_textbox.press("Enter")
test_id = f"studyBrowserListItem_simcore/services/{'dynamic' if service_type is ServiceType.DYNAMIC else 'comp'}"
test_id = f"serviceBrowserListItem_simcore/services/{'dynamic' if service_type is ServiceType.DYNAMIC else 'comp'}"
if service_key_prefix:
test_id = f"{test_id}/{service_key_prefix}"
test_id = f"{test_id}/{service_name}"
Expand All @@ -502,6 +577,19 @@ def _(plus_button_test_id: str) -> dict[str, Any]:
return _


@pytest.fixture
def create_project_from_template_dashboard(
find_and_click_template_in_dashboard: Callable[[str], None],
create_new_project_and_delete: Callable[[tuple[RunningState]], dict[str, Any]],
) -> Callable[[ServiceType, str, str | None], dict[str, Any]]:
def _(template_id: str) -> dict[str, Any]:
find_and_click_template_in_dashboard(template_id)
expected_states = (RunningState.UNKNOWN,)
return create_new_project_and_delete(expected_states, True, template_id)

return _


@pytest.fixture
def create_project_from_service_dashboard(
find_and_start_service_in_dashboard: Callable[[ServiceType, str, str | None], None],
Expand All @@ -516,7 +604,7 @@ def _(
expected_states = (RunningState.UNKNOWN,)
if service_type is ServiceType.COMPUTATIONAL:
expected_states = (RunningState.NOT_STARTED,)
return create_new_project_and_delete(expected_states)
return create_new_project_and_delete(expected_states, True)

return _

Expand Down
Loading

0 comments on commit f585573

Please sign in to comment.