diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/playwright_sim4life.py b/packages/pytest-simcore/src/pytest_simcore/helpers/playwright_sim4life.py new file mode 100644 index 00000000000..cb0d4089c3e --- /dev/null +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/playwright_sim4life.py @@ -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[^:]+)://(?P[^\.]+)\.services\.(?P[^\/]+)\/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) diff --git a/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js b/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js index 9b5e902c281..42e8b8d45c6 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js @@ -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); }, diff --git a/tests/e2e-playwright/tests/conftest.py b/tests/e2e-playwright/tests/conftest.py index cdae590ad8e..d7104c6fe70 100644 --- a/tests/e2e-playwright/tests/conftest.py +++ b/tests/e2e-playwright/tests/conftest.py @@ -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 @@ -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, @@ -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: @@ -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", @@ -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")) @@ -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 @@ -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() @@ -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, @@ -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}" @@ -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], @@ -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 _ diff --git a/tests/e2e-playwright/tests/sim4life/test_sim4life.py b/tests/e2e-playwright/tests/sim4life/test_sim4life.py index 3fca6f36b54..39b62039fbd 100644 --- a/tests/e2e-playwright/tests/sim4life/test_sim4life.py +++ b/tests/e2e-playwright/tests/sim4life/test_sim4life.py @@ -7,105 +7,16 @@ # pylint: disable=unused-variable -import datetime -import logging -import re from collections.abc import Callable -from dataclasses import dataclass -from typing import Any, Final - -import arrow -from playwright.sync_api import Page, WebSocket, expect -from pytest_simcore.helpers.logging_tools import log_context -from pytest_simcore.helpers.playwright import ( - MINUTE, - SECOND, - SOCKETIO_MESSAGE_PREFIX, - ServiceType, - SocketIOEvent, - decode_socketio_42_message, - wait_for_service_running, -) - -projects_uuid_pattern: Final[re.Pattern] = re.compile( - r"/projects/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})" +from typing import Any + +from playwright.sync_api import Page, WebSocket +from pytest_simcore.helpers.playwright import ServiceType +from pytest_simcore.helpers.playwright_sim4life import ( + launch_S4L, + interact_with_S4L, + check_video_streaming, ) -_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 -_S4L_STREAMING_ESTABLISHMENT_MAX_TIME: Final[int] = 15 * SECOND - - -_S4L_SOCKETIO_REGEX: Final[re.Pattern] = re.compile( - r"^(?P[^:]+)://(?P[^\.]+)\.services\.(?P[^\/]+)\/socket\.io\/.+$" -) - - -@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 -class _S4LSocketIOMessagePrinter: - def __call__(self, message: str) -> None: - if message.startswith(SOCKETIO_MESSAGE_PREFIX): - decoded_message: SocketIOEvent = decode_socketio_42_message(message) - print("S4L WS Message:", decoded_message.name, decoded_message.obj) - - -@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 test_sim4life( @@ -126,6 +37,7 @@ def test_sim4life( project_data = create_project_from_service_dashboard( ServiceType.DYNAMIC, service_key, None ) + assert "workbench" in project_data, "Expected workbench to be in project data!" assert isinstance( project_data["workbench"], dict @@ -133,58 +45,10 @@ def test_sim4life( node_ids: list[str] = list(project_data["workbench"]) assert len(node_ids) == 1, "Expected 1 node in the workbench!" - 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_ids[0], - 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!") - - # 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) + resp = launch_S4L(page, node_ids[0], log_in_and_out, autoscaled) + s4l_websocket = resp["websocket"] + s4l_iframe = resp["iframe"] + interact_with_S4L(page, s4l_iframe) if check_videostreaming: - 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) + check_video_streaming(page, s4l_iframe, s4l_websocket) diff --git a/tests/e2e-playwright/tests/sim4life/test_template.py b/tests/e2e-playwright/tests/sim4life/test_template.py new file mode 100644 index 00000000000..21f7387c2b2 --- /dev/null +++ b/tests/e2e-playwright/tests/sim4life/test_template.py @@ -0,0 +1,44 @@ +# pylint: disable=logging-fstring-interpolation +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unnecessary-lambda +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +from collections.abc import Callable +from typing import Any + +from playwright.sync_api import Page, WebSocket +from pytest_simcore.helpers.playwright_sim4life import ( + launch_S4L, + interact_with_S4L, + check_video_streaming, +) + + +def test_template( + page: Page, + create_project_from_template_dashboard: Callable[[str], dict[str, Any]], + log_in_and_out: WebSocket, + template_id: str, + autoscaled: bool, + check_videostreaming: bool, +): + project_data = create_project_from_template_dashboard(template_id) + + assert "workbench" in project_data, "Expected workbench to be in project data!" + assert isinstance( + project_data["workbench"], dict + ), "Expected workbench to be a dict!" + node_ids: list[str] = list(project_data["workbench"]) + assert len(node_ids) == 1, "Expected 1 node in the workbench!" + + resp = launch_S4L(page, node_ids[0], log_in_and_out, autoscaled) + s4l_websocket = resp["websocket"] + s4l_iframe = resp["iframe"] + interact_with_S4L(page, s4l_iframe) + + if check_videostreaming: + check_video_streaming(page, s4l_iframe, s4l_websocket)