Skip to content

Commit 5cf0635

Browse files
♻️ improving e2e tests (socketio reconnect) (#6759)
1 parent 063677b commit 5cf0635

File tree

8 files changed

+198
-92
lines changed

8 files changed

+198
-92
lines changed

packages/pytest-simcore/src/pytest_simcore/helpers/playwright.py

Lines changed: 105 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,25 @@
22
import json
33
import logging
44
import re
5+
import typing
56
from collections import defaultdict
6-
from collections.abc import Generator, Iterator
7+
from collections.abc import Generator
78
from dataclasses import dataclass, field
89
from datetime import UTC, datetime, timedelta
910
from enum import Enum, unique
1011
from typing import Any, Final
1112

1213
import httpx
14+
from playwright._impl._sync_base import EventContextManager
1315
from playwright.sync_api import FrameLocator, Page, Request
1416
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
1517
from playwright.sync_api import WebSocket
1618
from pydantic import AnyUrl
17-
from pytest_simcore.helpers.logging_tools import log_context
19+
20+
from .logging_tools import log_context
21+
22+
_logger = logging.getLogger(__name__)
23+
1824

1925
SECOND: Final[int] = 1000
2026
MINUTE: Final[int] = 60 * SECOND
@@ -106,6 +112,94 @@ class SocketIOEvent:
106112
SOCKETIO_MESSAGE_PREFIX: Final[str] = "42"
107113

108114

115+
@dataclass
116+
class RestartableWebSocket:
117+
page: Page
118+
ws: WebSocket
119+
_registered_events: list[tuple[str, typing.Callable | None]] = field(
120+
default_factory=list
121+
)
122+
_number_of_restarts: int = 0
123+
124+
def __post_init__(self):
125+
self._configure_websocket_events()
126+
127+
def _configure_websocket_events(self):
128+
try:
129+
with log_context(
130+
logging.DEBUG,
131+
msg="handle websocket message (set to --log-cli-level=DEBUG level if you wanna see all of them)",
132+
) as ctx:
133+
134+
def on_framesent(payload: str | bytes) -> None:
135+
ctx.logger.debug("⬇️ Frame sent: %s", payload)
136+
137+
def on_framereceived(payload: str | bytes) -> None:
138+
ctx.logger.debug("⬆️ Frame received: %s", payload)
139+
140+
def on_close(_: WebSocket) -> None:
141+
ctx.logger.warning(
142+
"⚠️ WebSocket closed. Attempting to reconnect..."
143+
)
144+
self._attempt_reconnect(ctx.logger)
145+
146+
def on_socketerror(error_msg: str) -> None:
147+
ctx.logger.error("❌ WebSocket error: %s", error_msg)
148+
149+
# Attach core event listeners
150+
self.ws.on("framesent", on_framesent)
151+
self.ws.on("framereceived", on_framereceived)
152+
self.ws.on("close", on_close)
153+
self.ws.on("socketerror", on_socketerror)
154+
155+
finally:
156+
# Detach core event listeners
157+
self.ws.remove_listener("framesent", on_framesent)
158+
self.ws.remove_listener("framereceived", on_framereceived)
159+
self.ws.remove_listener("close", on_close)
160+
self.ws.remove_listener("socketerror", on_socketerror)
161+
162+
def _attempt_reconnect(self, logger: logging.Logger) -> None:
163+
"""
164+
Attempt to reconnect the WebSocket and restore event listeners.
165+
"""
166+
try:
167+
with self.page.expect_websocket() as ws_info:
168+
assert not ws_info.value.is_closed()
169+
170+
self.ws = ws_info.value
171+
self._number_of_restarts += 1
172+
logger.info(
173+
"🔄 Reconnected to WebSocket successfully. Number of reconnections: %s",
174+
self._number_of_restarts,
175+
)
176+
self._configure_websocket_events()
177+
# Re-register all custom event listeners
178+
for event, predicate in self._registered_events:
179+
self.ws.expect_event(event, predicate)
180+
181+
except Exception as e: # pylint: disable=broad-except
182+
logger.error("🚨 Failed to reconnect WebSocket: %s", e)
183+
184+
def expect_event(
185+
self,
186+
event: str,
187+
predicate: typing.Callable | None = None,
188+
*,
189+
timeout: float | None = None,
190+
) -> EventContextManager:
191+
"""
192+
Register an event listener with support for reconnection.
193+
"""
194+
output = self.ws.expect_event(event, predicate, timeout=timeout)
195+
self._registered_events.append((event, predicate))
196+
return output
197+
198+
@classmethod
199+
def create(cls, page: Page, ws: WebSocket):
200+
return cls(page, ws)
201+
202+
109203
def decode_socketio_42_message(message: str) -> SocketIOEvent:
110204
data = json.loads(message.removeprefix(SOCKETIO_MESSAGE_PREFIX))
111205
return SocketIOEvent(name=data[0], obj=data[1])
@@ -278,7 +372,7 @@ def get_partial_product_url(self):
278372
def wait_for_pipeline_state(
279373
current_state: RunningState,
280374
*,
281-
websocket: WebSocket,
375+
websocket: RestartableWebSocket,
282376
if_in_states: tuple[RunningState, ...],
283377
expected_states: tuple[RunningState, ...],
284378
timeout_ms: int,
@@ -301,39 +395,6 @@ def wait_for_pipeline_state(
301395
return current_state
302396

303397

304-
@contextlib.contextmanager
305-
def web_socket_default_log_handler(web_socket: WebSocket) -> Iterator[None]:
306-
307-
try:
308-
with log_context(
309-
logging.DEBUG,
310-
msg="handle websocket message (set to --log-cli-level=DEBUG level if you wanna see all of them)",
311-
) as ctx:
312-
313-
def on_framesent(payload: str | bytes) -> None:
314-
ctx.logger.debug("⬇️ Frame sent: %s", payload)
315-
316-
def on_framereceived(payload: str | bytes) -> None:
317-
ctx.logger.debug("⬆️ Frame received: %s", payload)
318-
319-
def on_close(payload: WebSocket) -> None:
320-
ctx.logger.warning("⚠️ Websocket closed: %s", payload)
321-
322-
def on_socketerror(error_msg: str) -> None:
323-
ctx.logger.error("❌ Websocket error: %s", error_msg)
324-
325-
web_socket.on("framesent", on_framesent)
326-
web_socket.on("framereceived", on_framereceived)
327-
web_socket.on("close", on_close)
328-
web_socket.on("socketerror", on_socketerror)
329-
yield
330-
finally:
331-
web_socket.remove_listener("framesent", on_framesent)
332-
web_socket.remove_listener("framereceived", on_framereceived)
333-
web_socket.remove_listener("close", on_close)
334-
web_socket.remove_listener("socketerror", on_socketerror)
335-
336-
337398
def _node_started_predicate(request: Request) -> bool:
338399
return bool(
339400
re.search(NODE_START_REQUEST_PATTERN, request.url)
@@ -358,12 +419,14 @@ def expected_service_running(
358419
*,
359420
page: Page,
360421
node_id: str,
361-
websocket: WebSocket,
422+
websocket: RestartableWebSocket,
362423
timeout: int,
363424
press_start_button: bool,
364425
product_url: AnyUrl,
365426
) -> Generator[ServiceRunning, None, None]:
366-
with log_context(logging.INFO, msg="Waiting for node to run") as ctx:
427+
with log_context(
428+
logging.INFO, msg=f"Waiting for node to run. Timeout: {timeout}"
429+
) as ctx:
367430
waiter = SocketIONodeProgressCompleteWaiter(
368431
node_id=node_id, logger=ctx.logger, product_url=product_url
369432
)
@@ -395,15 +458,17 @@ def wait_for_service_running(
395458
*,
396459
page: Page,
397460
node_id: str,
398-
websocket: WebSocket,
461+
websocket: RestartableWebSocket,
399462
timeout: int,
400463
press_start_button: bool,
401464
product_url: AnyUrl,
402465
) -> FrameLocator:
403466
"""NOTE: if the service was already started this will not work as some of the required websocket events will not be emitted again
404467
In which case this will need further adjutment"""
405468

406-
with log_context(logging.INFO, msg="Waiting for node to run") as ctx:
469+
with log_context(
470+
logging.INFO, msg=f"Waiting for node to run. Timeout: {timeout}"
471+
) as ctx:
407472
waiter = SocketIONodeProgressCompleteWaiter(
408473
node_id=node_id, logger=ctx.logger, product_url=product_url
409474
)

packages/pytest-simcore/src/pytest_simcore/helpers/playwright_sim4life.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
MINUTE,
1414
SECOND,
1515
SOCKETIO_MESSAGE_PREFIX,
16+
RestartableWebSocket,
1617
SocketIOEvent,
1718
decode_socketio_42_message,
1819
wait_for_service_running,
@@ -100,7 +101,7 @@ class WaitForS4LDict(TypedDict):
100101
def wait_for_launched_s4l(
101102
page: Page,
102103
node_id,
103-
log_in_and_out: WebSocket,
104+
log_in_and_out: RestartableWebSocket,
104105
*,
105106
autoscaled: bool,
106107
copy_workspace: bool,

tests/e2e-playwright/tests/conftest.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import arrow
2121
import pytest
2222
from faker import Faker
23-
from playwright.sync_api import APIRequestContext, BrowserContext, Page, WebSocket
23+
from playwright.sync_api import APIRequestContext, BrowserContext, Page
2424
from playwright.sync_api._generated import Playwright
2525
from pydantic import AnyUrl, TypeAdapter
2626
from pytest_simcore.helpers.faker_factories import DEFAULT_TEST_PASSWORD
@@ -29,13 +29,13 @@
2929
MINUTE,
3030
SECOND,
3131
AutoRegisteredUser,
32+
RestartableWebSocket,
3233
RunningState,
3334
ServiceType,
3435
SocketIOEvent,
3536
SocketIOProjectClosedWaiter,
3637
SocketIOProjectStateUpdatedWaiter,
3738
decode_socketio_42_message,
38-
web_socket_default_log_handler,
3939
)
4040
from pytest_simcore.helpers.pydantic_extension import Secret4TestsStr
4141

@@ -331,7 +331,7 @@ def log_in_and_out(
331331
user_password: Secret4TestsStr,
332332
auto_register: bool,
333333
register: Callable[[], AutoRegisteredUser],
334-
) -> Iterator[WebSocket]:
334+
) -> Iterator[RestartableWebSocket]:
335335
with log_context(
336336
logging.INFO,
337337
f"Open {product_url=} using {user_name=}/{user_password=}/{auto_register=}",
@@ -374,8 +374,8 @@ def log_in_and_out(
374374
page.get_by_test_id("loginSubmitBtn").click()
375375
assert response_info.value.ok, f"{response_info.value.json()}"
376376

377-
ws = ws_info.value
378-
assert not ws.is_closed()
377+
assert not ws_info.value.is_closed()
378+
restartable_wb = RestartableWebSocket.create(page, ws_info.value)
379379

380380
# Welcome to Sim4Life
381381
page.wait_for_timeout(5000)
@@ -389,8 +389,8 @@ def log_in_and_out(
389389
if quickStartWindowCloseBtnLocator.is_visible():
390390
quickStartWindowCloseBtnLocator.click()
391391

392-
with web_socket_default_log_handler(ws):
393-
yield ws
392+
# with web_socket_default_log_handler(ws):
393+
yield restartable_wb
394394

395395
with log_context(
396396
logging.INFO,
@@ -408,7 +408,7 @@ def log_in_and_out(
408408
@pytest.fixture
409409
def create_new_project_and_delete(
410410
page: Page,
411-
log_in_and_out: WebSocket,
411+
log_in_and_out: RestartableWebSocket,
412412
is_product_billable: bool,
413413
api_request_context: APIRequestContext,
414414
product_url: AnyUrl,
@@ -660,7 +660,7 @@ def _(
660660
def start_and_stop_pipeline(
661661
product_url: AnyUrl,
662662
page: Page,
663-
log_in_and_out: WebSocket,
663+
log_in_and_out: RestartableWebSocket,
664664
api_request_context: APIRequestContext,
665665
) -> Iterator[Callable[[], SocketIOEvent]]:
666666
started_pipeline_ids = []

tests/e2e-playwright/tests/jupyterlabs/test_jupyterlab.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@
1616
from playwright.sync_api import Page, WebSocket
1717
from pydantic import ByteSize
1818
from pytest_simcore.helpers.logging_tools import log_context
19-
from pytest_simcore.helpers.playwright import MINUTE, SECOND, ServiceType
19+
from pytest_simcore.helpers.playwright import (
20+
MINUTE,
21+
SECOND,
22+
RestartableWebSocket,
23+
ServiceType,
24+
)
2025

2126
_WAITING_FOR_SERVICE_TO_START: Final[int] = (
2227
10 * MINUTE
@@ -110,8 +115,11 @@ def test_jupyterlab(
110115
iframe.get_by_role("button", name="New Launcher").click()
111116
with page.expect_websocket(_JLabWaitForTerminalWebSocket()) as ws_info:
112117
iframe.get_by_label("Launcher").get_by_text("Terminal").click()
113-
terminal_web_socket = ws_info.value
114-
assert not terminal_web_socket.is_closed()
118+
119+
assert not ws_info.value.is_closed()
120+
restartable_terminal_web_socket = RestartableWebSocket.create(
121+
page, ws_info.value
122+
)
115123

116124
terminal = iframe.locator(
117125
"#jp-Terminal-0 > div > div.xterm-screen"
@@ -122,7 +130,7 @@ def test_jupyterlab(
122130
terminal.press("Enter")
123131
# NOTE: this call creates a large file with random blocks inside
124132
blocks_count = int(large_file_size / large_file_block_size)
125-
with terminal_web_socket.expect_event(
133+
with restartable_terminal_web_socket.expect_event(
126134
"framereceived",
127135
_JLabTerminalWebSocketWaiter(
128136
expected_message_type="stdout", expected_message_contents="copied"

tests/e2e-playwright/tests/sim4life/test_sim4life.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,9 @@
1010
from collections.abc import Callable
1111
from typing import Any
1212

13-
from playwright.sync_api import Page, WebSocket
13+
from playwright.sync_api import Page
1414
from pydantic import AnyUrl
15-
from pytest_simcore.helpers.playwright import (
16-
ServiceType,
17-
web_socket_default_log_handler,
18-
)
15+
from pytest_simcore.helpers.playwright import RestartableWebSocket, ServiceType
1916
from pytest_simcore.helpers.playwright_sim4life import (
2017
check_video_streaming,
2118
interact_with_s4l,
@@ -29,7 +26,7 @@ def test_sim4life(
2926
[ServiceType, str, str | None], dict[str, Any]
3027
],
3128
create_project_from_new_button: Callable[[str], dict[str, Any]],
32-
log_in_and_out: WebSocket,
29+
log_in_and_out: RestartableWebSocket,
3330
service_key: str,
3431
use_plus_button: bool,
3532
is_autoscaled: bool,
@@ -59,9 +56,8 @@ def test_sim4life(
5956
product_url=product_url,
6057
)
6158
s4l_websocket = resp["websocket"]
62-
with web_socket_default_log_handler(s4l_websocket):
63-
s4l_iframe = resp["iframe"]
64-
interact_with_s4l(page, s4l_iframe)
59+
s4l_iframe = resp["iframe"]
60+
interact_with_s4l(page, s4l_iframe)
6561

66-
if check_videostreaming:
67-
check_video_streaming(page, s4l_iframe, s4l_websocket)
62+
if check_videostreaming:
63+
check_video_streaming(page, s4l_iframe, s4l_websocket)

0 commit comments

Comments
 (0)