Skip to content

Commit f585573

Browse files
odeimaizsanderegg
andauthored
🎨 [e2e] Start from template playwright test (#6225)
Co-authored-by: sanderegg <[email protected]>
1 parent 2e2993c commit f585573

File tree

5 files changed

+302
-156
lines changed

5 files changed

+302
-156
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import datetime
2+
import logging
3+
import re
4+
from dataclasses import dataclass
5+
from typing import Dict, Final, Union
6+
7+
import arrow
8+
from playwright.sync_api import FrameLocator, Page, WebSocket, expect
9+
10+
from .logging_tools import log_context
11+
from .playwright import (
12+
SECOND,
13+
MINUTE,
14+
SOCKETIO_MESSAGE_PREFIX,
15+
SocketIOEvent,
16+
decode_socketio_42_message,
17+
wait_for_service_running,
18+
)
19+
20+
_S4L_STREAMING_ESTABLISHMENT_MAX_TIME: Final[int] = 15 * SECOND
21+
_S4L_SOCKETIO_REGEX: Final[re.Pattern] = re.compile(
22+
r"^(?P<protocol>[^:]+)://(?P<node_id>[^\.]+)\.services\.(?P<hostname>[^\/]+)\/socket\.io\/.+$"
23+
)
24+
_EC2_STARTUP_MAX_WAIT_TIME: Final[int] = 1 * MINUTE
25+
_S4L_MAX_STARTUP_TIME: Final[int] = 1 * MINUTE
26+
_S4L_DOCKER_PULLING_MAX_TIME: Final[int] = 10 * MINUTE
27+
_S4L_AUTOSCALED_MAX_STARTUP_TIME: Final[int] = (
28+
_EC2_STARTUP_MAX_WAIT_TIME + _S4L_DOCKER_PULLING_MAX_TIME + _S4L_MAX_STARTUP_TIME
29+
)
30+
_S4L_STARTUP_SCREEN_MAX_TIME: Final[int] = 45 * SECOND
31+
32+
33+
@dataclass(kw_only=True)
34+
class S4LWaitForWebsocket:
35+
logger: logging.Logger
36+
37+
def __call__(self, new_websocket: WebSocket) -> bool:
38+
if re.match(_S4L_SOCKETIO_REGEX, new_websocket.url):
39+
self.logger.info("found S4L websocket!")
40+
return True
41+
42+
return False
43+
44+
45+
@dataclass(kw_only=True)
46+
class _S4LSocketIOCheckBitRateIncreasesMessagePrinter:
47+
observation_time: datetime.timedelta
48+
logger: logging.Logger
49+
_initial_bit_rate: float = 0
50+
_initial_bit_rate_time: datetime.datetime = arrow.utcnow().datetime
51+
52+
def __call__(self, message: str) -> bool:
53+
if message.startswith(SOCKETIO_MESSAGE_PREFIX):
54+
decoded_message: SocketIOEvent = decode_socketio_42_message(message)
55+
if (
56+
decoded_message.name == "server.video_stream.bitrate_data"
57+
and "bitrate" in decoded_message.obj
58+
):
59+
current_bitrate = decoded_message.obj["bitrate"]
60+
if self._initial_bit_rate == 0:
61+
self._initial_bit_rate = current_bitrate
62+
self._initial_bit_rate_time = arrow.utcnow().datetime
63+
self.logger.info(
64+
"%s",
65+
f"{self._initial_bit_rate=} at {self._initial_bit_rate_time.isoformat()}",
66+
)
67+
return False
68+
69+
# NOTE: MaG says the value might also go down, but it shall definitely change,
70+
# if this code proves unsafe we should change it.
71+
elapsed_time = arrow.utcnow().datetime - self._initial_bit_rate_time
72+
if (
73+
elapsed_time > self.observation_time
74+
and "bitrate" in decoded_message.obj
75+
):
76+
current_bitrate = decoded_message.obj["bitrate"]
77+
bitrate_test = bool(self._initial_bit_rate != current_bitrate)
78+
self.logger.info(
79+
"%s",
80+
f"{current_bitrate=} after {elapsed_time=}: {'good!' if bitrate_test else 'failed! bitrate did not change! TIP: talk with MaG about underwater cables!'}",
81+
)
82+
return bitrate_test
83+
84+
return False
85+
86+
87+
def launch_S4L(page: Page, node_id, log_in_and_out: WebSocket, autoscaled: bool) -> Dict[str, Union[WebSocket, FrameLocator]]:
88+
with log_context(logging.INFO, "launch S4L") as ctx:
89+
predicate = S4LWaitForWebsocket(logger=ctx.logger)
90+
with page.expect_websocket(
91+
predicate,
92+
timeout=_S4L_STARTUP_SCREEN_MAX_TIME
93+
+ (
94+
_S4L_AUTOSCALED_MAX_STARTUP_TIME
95+
if autoscaled
96+
else _S4L_MAX_STARTUP_TIME
97+
)
98+
+ 10 * SECOND,
99+
) as ws_info:
100+
s4l_iframe = wait_for_service_running(
101+
page=page,
102+
node_id=node_id,
103+
websocket=log_in_and_out,
104+
timeout=(
105+
_S4L_AUTOSCALED_MAX_STARTUP_TIME
106+
if autoscaled
107+
else _S4L_MAX_STARTUP_TIME
108+
),
109+
press_start_button=False,
110+
)
111+
s4l_websocket = ws_info.value
112+
ctx.logger.info("acquired S4L websocket!")
113+
return {
114+
"websocket": s4l_websocket,
115+
"iframe" : s4l_iframe,
116+
}
117+
118+
119+
def interact_with_S4L(page: Page, s4l_iframe: FrameLocator) -> None:
120+
# Wait until grid is shown
121+
# NOTE: the startup screen should disappear very fast after the websocket was acquired
122+
with log_context(logging.INFO, "Interact with S4l"):
123+
s4l_iframe.get_by_test_id("tree-item-Grid").nth(0).click()
124+
page.wait_for_timeout(3000)
125+
126+
127+
def check_video_streaming(page: Page, s4l_iframe: FrameLocator, s4l_websocket: WebSocket) -> None:
128+
with log_context(logging.INFO, "Check videostreaming works") as ctx:
129+
waiter = _S4LSocketIOCheckBitRateIncreasesMessagePrinter(
130+
observation_time=datetime.timedelta(
131+
milliseconds=_S4L_STREAMING_ESTABLISHMENT_MAX_TIME / 2.0,
132+
),
133+
logger=ctx.logger,
134+
)
135+
with s4l_websocket.expect_event(
136+
"framereceived",
137+
waiter,
138+
timeout=_S4L_STREAMING_ESTABLISHMENT_MAX_TIME,
139+
):
140+
...
141+
142+
expect(
143+
s4l_iframe.locator("video"),
144+
"videostreaming is not established. "
145+
"TIP: if using playwright integrated open source chromIUM, "
146+
"webkit or firefox this is expected, switch to chrome/msedge!!",
147+
).to_be_visible()
148+
s4l_iframe.locator("video").click()
149+
page.wait_for_timeout(3000)

services/static-webserver/client/source/class/osparc/dashboard/CardBase.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,8 @@ qx.Class.define("osparc.dashboard.CardBase", {
449449
},
450450

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

454455
this.setCardKey(value);
455456
},

tests/e2e-playwright/tests/conftest.py

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import os
1212
import random
1313
import re
14+
import urllib.parse
1415
from collections.abc import Callable, Iterator
1516
from contextlib import ExitStack
1617
from typing import Any, Final
@@ -24,6 +25,7 @@
2425
from pytest import Item
2526
from pytest_simcore.helpers.logging_tools import log_context
2627
from pytest_simcore.helpers.playwright import (
28+
SECOND,
2729
MINUTE,
2830
AutoRegisteredUser,
2931
RunningState,
@@ -35,6 +37,8 @@
3537
)
3638

3739
_PROJECT_CLOSING_TIMEOUT: Final[int] = 10 * MINUTE
40+
_OPENING_NEW_EMPTY_PROJECT_MAX_WAIT_TIME: Final[int] = 30 * SECOND
41+
_OPENING_TUTORIAL_MAX_WAIT_TIME: Final[int] = 3 * MINUTE
3842

3943

4044
def pytest_addoption(parser: pytest.Parser) -> None:
@@ -94,6 +98,13 @@ def pytest_addoption(parser: pytest.Parser) -> None:
9498
default=None,
9599
help="Service Key",
96100
)
101+
group.addoption(
102+
"--template-id",
103+
action="store",
104+
type=str,
105+
default=None,
106+
help="Template uuid",
107+
)
97108
group.addoption(
98109
"--user-agent",
99110
action="store",
@@ -232,6 +243,14 @@ def service_key(request: pytest.FixtureRequest) -> str:
232243
return os.environ["SERVICE_KEY"]
233244

234245

246+
@pytest.fixture(scope="session")
247+
def template_id(request: pytest.FixtureRequest) -> str | None:
248+
if key := request.config.getoption("--template-id"):
249+
assert isinstance(key, str)
250+
return key
251+
return None
252+
253+
235254
@pytest.fixture(scope="session")
236255
def auto_register(request: pytest.FixtureRequest) -> bool:
237256
return bool(request.config.getoption("--autoregister"))
@@ -381,6 +400,7 @@ def create_new_project_and_delete(
381400
def _(
382401
expected_states: tuple[RunningState] = (RunningState.NOT_STARTED,),
383402
press_open: bool = True,
403+
template_id: str | None = None,
384404
) -> dict[str, Any]:
385405
assert (
386406
len(created_project_uuids) == 0
@@ -390,15 +410,54 @@ def _(
390410
f"Open project in {product_url=} as {product_billable=}",
391411
) as ctx:
392412
waiter = SocketIOProjectStateUpdatedWaiter(expected_states=expected_states)
413+
timeout = _OPENING_TUTORIAL_MAX_WAIT_TIME if template_id is not None else _OPENING_NEW_EMPTY_PROJECT_MAX_WAIT_TIME
393414
with (
394-
log_in_and_out.expect_event("framereceived", waiter),
415+
log_in_and_out.expect_event("framereceived", waiter, timeout=timeout + 10 * SECOND),
395416
page.expect_response(
396-
re.compile(r"/projects/[^:]+:open")
417+
re.compile(r"/projects/[^:]+:open"),
418+
timeout=timeout + 5 * SECOND
397419
) as response_info,
398420
):
399421
# Project detail view pop-ups shows
400422
if press_open:
401-
page.get_by_test_id("openResource").click()
423+
open_button = page.get_by_test_id("openResource")
424+
if template_id is not None:
425+
# it returns a Long Running Task
426+
with page.expect_response(
427+
re.compile(rf"/projects\?from_study\={template_id}")
428+
) as lrt:
429+
open_button.click()
430+
lrt_data = lrt.value.json()
431+
lrt_data = lrt_data["data"]
432+
with log_context(
433+
logging.INFO,
434+
"Copying template data",
435+
) as copying_logger:
436+
# From the long running tasks response's urls, only their path is relevant
437+
def url_to_path(url):
438+
return urllib.parse.urlparse(url).path
439+
def wait_for_done(response):
440+
if url_to_path(response.url) == url_to_path(lrt_data["status_href"]):
441+
resp_data = response.json()
442+
resp_data = resp_data["data"]
443+
assert "task_progress" in resp_data
444+
task_progress = resp_data["task_progress"]
445+
copying_logger.logger.info(
446+
"task progress: %s %s",
447+
task_progress["percent"],
448+
task_progress["message"],
449+
)
450+
return False
451+
if url_to_path(response.url) == url_to_path(lrt_data["result_href"]):
452+
copying_logger.logger.info("project created")
453+
return response.status == 201
454+
return False
455+
with page.expect_response(wait_for_done, timeout=timeout):
456+
# if the above calls go to fast, this test could fail
457+
# not expected in the sim4life context though
458+
...
459+
else:
460+
open_button.click()
402461
if product_billable:
403462
# Open project with default resources
404463
page.get_by_test_id("openWithResources").click()
@@ -466,6 +525,22 @@ def _(plus_button_test_id: str) -> None:
466525
return _
467526

468527

528+
@pytest.fixture
529+
def find_and_click_template_in_dashboard(
530+
page: Page,
531+
) -> Callable[[str], None]:
532+
def _(template_id: str) -> None:
533+
with log_context(logging.INFO, f"Finding {template_id=} in dashboard"):
534+
page.get_by_test_id("templatesTabBtn").click()
535+
_textbox = page.get_by_test_id("searchBarFilter-textField-template")
536+
_textbox.fill(template_id)
537+
_textbox.press("Enter")
538+
test_id = "templateBrowserListItem_" + template_id
539+
page.get_by_test_id(test_id).click()
540+
541+
return _
542+
543+
469544
@pytest.fixture
470545
def find_and_start_service_in_dashboard(
471546
page: Page,
@@ -478,7 +553,7 @@ def _(
478553
_textbox = page.get_by_test_id("searchBarFilter-textField-service")
479554
_textbox.fill(service_name)
480555
_textbox.press("Enter")
481-
test_id = f"studyBrowserListItem_simcore/services/{'dynamic' if service_type is ServiceType.DYNAMIC else 'comp'}"
556+
test_id = f"serviceBrowserListItem_simcore/services/{'dynamic' if service_type is ServiceType.DYNAMIC else 'comp'}"
482557
if service_key_prefix:
483558
test_id = f"{test_id}/{service_key_prefix}"
484559
test_id = f"{test_id}/{service_name}"
@@ -502,6 +577,19 @@ def _(plus_button_test_id: str) -> dict[str, Any]:
502577
return _
503578

504579

580+
@pytest.fixture
581+
def create_project_from_template_dashboard(
582+
find_and_click_template_in_dashboard: Callable[[str], None],
583+
create_new_project_and_delete: Callable[[tuple[RunningState]], dict[str, Any]],
584+
) -> Callable[[ServiceType, str, str | None], dict[str, Any]]:
585+
def _(template_id: str) -> dict[str, Any]:
586+
find_and_click_template_in_dashboard(template_id)
587+
expected_states = (RunningState.UNKNOWN,)
588+
return create_new_project_and_delete(expected_states, True, template_id)
589+
590+
return _
591+
592+
505593
@pytest.fixture
506594
def create_project_from_service_dashboard(
507595
find_and_start_service_in_dashboard: Callable[[ServiceType, str, str | None], None],
@@ -516,7 +604,7 @@ def _(
516604
expected_states = (RunningState.UNKNOWN,)
517605
if service_type is ServiceType.COMPUTATIONAL:
518606
expected_states = (RunningState.NOT_STARTED,)
519-
return create_new_project_and_delete(expected_states)
607+
return create_new_project_and_delete(expected_states, True)
520608

521609
return _
522610

0 commit comments

Comments
 (0)