Skip to content

Commit 696ff79

Browse files
author
Andrei Neagu
committed
added base tests with playwright
1 parent 46fb5d0 commit 696ff79

File tree

8 files changed

+276
-180
lines changed

8 files changed

+276
-180
lines changed

services/dynamic-scheduler/requirements/_test.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ asgi_lifespan
1515
coverage
1616
docker
1717
faker
18+
hypercorn
19+
playwright
1820
pytest
1921
pytest-asyncio
2022
pytest-cov

services/dynamic-scheduler/requirements/_test.txt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,20 @@ docker==7.1.0
2323
# via -r requirements/_test.in
2424
faker==33.0.0
2525
# via -r requirements/_test.in
26+
greenlet==3.1.1
27+
# via
28+
# -c requirements/_base.txt
29+
# playwright
2630
h11==0.14.0
2731
# via
2832
# -c requirements/_base.txt
2933
# httpcore
34+
# hypercorn
35+
# wsproto
36+
h2==4.1.0
37+
# via hypercorn
38+
hpack==4.0.0
39+
# via h2
3040
httpcore==1.0.7
3141
# via
3242
# -c requirements/_base.txt
@@ -36,6 +46,10 @@ httpx==0.27.2
3646
# -c requirements/../../../requirements/constraints.txt
3747
# -c requirements/_base.txt
3848
# respx
49+
hypercorn==0.17.3
50+
# via -r requirements/_test.in
51+
hyperframe==6.0.1
52+
# via h2
3953
icdiff==2.0.7
4054
# via pytest-icdiff
4155
idna==3.10
@@ -51,10 +65,16 @@ packaging==24.2
5165
# -c requirements/_base.txt
5266
# pytest
5367
# pytest-sugar
68+
playwright==1.49.0
69+
# via -r requirements/_test.in
5470
pluggy==1.5.0
5571
# via pytest
5672
pprintpp==0.4.0
5773
# via pytest-icdiff
74+
priority==2.0.0
75+
# via hypercorn
76+
pyee==12.0.0
77+
# via playwright
5878
pytest==8.3.3
5979
# via
6080
# -r requirements/_test.in
@@ -107,9 +127,14 @@ typing-extensions==4.12.2
107127
# via
108128
# -c requirements/_base.txt
109129
# faker
130+
# pyee
110131
urllib3==2.2.3
111132
# via
112133
# -c requirements/../../../requirements/constraints.txt
113134
# -c requirements/_base.txt
114135
# docker
115136
# requests
137+
wsproto==1.2.0
138+
# via
139+
# -c requirements/_base.txt
140+
# hypercorn

services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/routes/_index.py

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from ....services.service_tracker import TrackedServiceModel, get_all_tracked_services
1212
from ....services.service_tracker._models import SchedulerServiceState
1313
from .._utils import get_parent_app
14-
from . import markers
1514
from ._render_utils import base_page, get_iso_formatted_date
1615

1716
router = APIRouter()
@@ -49,7 +48,7 @@ def _render_service_details(node_id: NodeID, service: TrackedServiceModel) -> No
4948
),
5049
)
5150

52-
with ui.column().classes("gap-0").mark(markers.INDEX_SERVICE_CARD):
51+
with ui.column().classes("gap-0"):
5352
for key, (widget, value) in dict_to_render.items():
5453
with ui.row(align_items="baseline"):
5554
ui.label(key).classes("font-bold")
@@ -63,55 +62,42 @@ def _render_service_details(node_id: NodeID, service: TrackedServiceModel) -> No
6362

6463

6564
def _render_buttons(node_id: NodeID, service: TrackedServiceModel) -> None:
66-
async def _display_confirm_dialog():
67-
with ui.dialog() as confirm_dialog, ui.card():
68-
confirm_dialog.mark(markers.INDEX_SERVICE_CARD_STOP_CONFIRM_DIALOG)
69-
70-
ui.markdown(f"Stop service **{node_id}**?")
71-
ui.label("The service will be stopped and its data will be saved.")
72-
with ui.row():
73-
ui.button(
74-
"Stop",
75-
color="red",
76-
on_click=lambda: confirm_dialog.submit("Stop"),
77-
)
78-
ui.button("No", on_click=lambda: confirm_dialog.submit("No")).mark(
79-
markers.INDEX_SERVICE_CARD_STOP_CONFIRM_DIALOG_NO_BUTTON
80-
)
8165

82-
click_result = await confirm_dialog
66+
with ui.dialog() as confirm_dialog, ui.card():
8367

84-
if click_result != "Stop":
85-
return
68+
ui.markdown(f"Stop service **{node_id}**?")
69+
ui.label("The service will be stopped and its data will be saved.")
70+
with ui.row():
8671

87-
await httpx.AsyncClient(timeout=10).get(
88-
f"http://localhost:{DEFAULT_FASTAPI_PORT}/service/{node_id}:stop"
89-
)
72+
async def _stop_service() -> None:
73+
confirm_dialog.close()
74+
await httpx.AsyncClient(timeout=10).get(
75+
f"http://localhost:{DEFAULT_FASTAPI_PORT}/service/{node_id}:stop"
76+
)
9077

91-
ui.notify(
92-
f"Submitted stop request for {node_id}. Please give the service some time to stop!"
93-
)
78+
ui.notify(
79+
f"Submitted stop request for {node_id}. Please give the service some time to stop!"
80+
)
81+
82+
ui.button("Stop Now", color="red", on_click=_stop_service)
83+
ui.button("Cancel", on_click=confirm_dialog.close)
9484

9585
with ui.button_group():
9686
ui.button(
9787
"Details",
9888
icon="source",
9989
on_click=lambda: ui.navigate.to(f"/service/{node_id}:details"),
100-
).tooltip("Display more information about what the scheduler is tracking").mark(
101-
markers.INDEX_SERVICE_CARD_DETAILS_BUTTON
102-
)
90+
).tooltip("Display more information about what the scheduler is tracking")
10391

10492
if service.current_state != SchedulerServiceState.RUNNING:
10593
return
10694

10795
ui.button(
108-
"Stop service",
96+
"Stop Service",
10997
icon="stop",
11098
color="orange",
111-
on_click=_display_confirm_dialog,
112-
).tooltip("Stops the service and saves the data").mark(
113-
markers.INDEX_SERVICE_CARD_STOP_BUTTON
114-
)
99+
on_click=confirm_dialog.open,
100+
).tooltip("Stops the service and saves the data")
115101

116102

117103
def _render_card(
@@ -169,10 +155,10 @@ async def update(self) -> None:
169155
async def index():
170156
with base_page():
171157
with ui.row().classes("gap-0"):
172-
ui.label("Total tracked services:").mark(markers.INDEX_TOTAL_SERVICES_LABEL)
158+
ui.label("Total tracked services:")
173159
ui.label("").classes("w-1")
174160
with ui.label("0") as services_count_label:
175-
services_count_label.mark(markers.INDEX_TOTAL_SERVICES_COUNT_LABEL)
161+
pass
176162

177163
card_container: Element = ui.row()
178164

services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/api/frontend/routes/markers.py

Lines changed: 0 additions & 14 deletions
This file was deleted.

services/dynamic-scheduler/tests/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from simcore_service_dynamic_scheduler.core.application import create_app
2121

2222
pytest_plugins = [
23-
"nicegui.testing.user_plugin",
2423
"pytest_simcore.cli_runner",
2524
"pytest_simcore.docker_compose",
2625
"pytest_simcore.docker_swarm",
Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
# pylint:disable=redefined-outer-name
22
# pylint:disable=unused-argument
33

4-
from typing import AsyncIterator
4+
import asyncio
5+
import subprocess
6+
from collections.abc import AsyncIterable
7+
from contextlib import suppress
58

69
import pytest
7-
from fastapi import FastAPI
8-
from httpx import ASGITransport, AsyncClient
9-
from nicegui.testing.user import User
10+
from fastapi import FastAPI, status
11+
from httpx import AsyncClient
12+
from hypercorn.asyncio import serve
13+
from hypercorn.config import Config
14+
from playwright.async_api import Page, async_playwright
1015
from pytest_mock import MockerFixture
1116
from pytest_simcore.helpers.typing_env import EnvVarsDict
1217
from settings_library.rabbit import RabbitSettings
1318
from settings_library.redis import RedisSettings
19+
from simcore_service_dynamic_scheduler.core.application import create_app
20+
from tenacity import AsyncRetrying, stop_after_delay, wait_fixed
1421

1522

1623
@pytest.fixture
@@ -32,21 +39,61 @@ def app_environment(
3239

3340

3441
@pytest.fixture
35-
async def client(
36-
app_environment: EnvVarsDict, app: FastAPI
37-
) -> AsyncIterator[AsyncClient]:
38-
# - Needed for app to trigger start/stop event handlers
39-
# - Prefer this client instead of fastapi.testclient.TestClient
40-
async with AsyncClient(
41-
app=app,
42-
base_url="http://payments.testserver.io",
43-
headers={"Content-Type": "application/json"},
44-
) as httpx_client:
45-
# pylint:disable=protected-access
46-
assert isinstance(httpx_client._transport, ASGITransport) # noqa: SLF001
47-
yield httpx_client
42+
def server_host_port() -> str:
43+
return "127.0.0.1:7456"
4844

4945

5046
@pytest.fixture
51-
async def user(client: AsyncClient) -> User:
52-
return User(client)
47+
def not_initialized_app(app_environment: EnvVarsDict) -> FastAPI:
48+
return create_app()
49+
50+
51+
@pytest.fixture
52+
async def app_runner(
53+
not_initialized_app: FastAPI, server_host_port: str
54+
) -> AsyncIterable[None]:
55+
56+
shutdown_event = asyncio.Event()
57+
58+
async def _wait_for_shutdown_event():
59+
await shutdown_event.wait()
60+
61+
async def _run_server() -> None:
62+
config = Config()
63+
config.bind = [server_host_port]
64+
65+
with suppress(asyncio.CancelledError):
66+
await serve(
67+
not_initialized_app, config, shutdown_trigger=_wait_for_shutdown_event
68+
)
69+
70+
server_task = asyncio.create_task(_run_server())
71+
72+
async for attempt in AsyncRetrying(
73+
reraise=True, wait=wait_fixed(0.1), stop=stop_after_delay(2)
74+
):
75+
with attempt:
76+
async with AsyncClient(timeout=1) as client:
77+
result = await client.get(f"http://{server_host_port}")
78+
assert result.status_code == status.HTTP_200_OK
79+
80+
yield
81+
82+
shutdown_event.set()
83+
await server_task
84+
85+
86+
@pytest.fixture
87+
def download_playwright_browser() -> None:
88+
subprocess.run( # noqa: S603
89+
["playwright", "install", "chromium"], check=True # noqa: S607
90+
)
91+
92+
93+
@pytest.fixture
94+
async def async_page(download_playwright_browser: None) -> AsyncIterable[Page]:
95+
async with async_playwright() as p:
96+
browser = await p.chromium.launch()
97+
page = await browser.new_page()
98+
yield page
99+
await browser.close()
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# pylint:disable=redefined-outer-name
2+
# pylint:disable=unused-argument
3+
4+
import sys
5+
from collections.abc import AsyncIterator
6+
from contextlib import asynccontextmanager
7+
from pathlib import Path
8+
from typing import Final
9+
from uuid import uuid4
10+
11+
from playwright.async_api import Locator, Page
12+
from pydantic import NonNegativeFloat, NonNegativeInt
13+
from tenacity import AsyncRetrying, stop_after_delay, wait_fixed
14+
15+
_HERE: Final[Path] = (
16+
Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent
17+
)
18+
_DEFAULT_TIMEOUT: Final[NonNegativeFloat] = 10
19+
20+
21+
@asynccontextmanager
22+
async def _take_screenshot_on_error(
23+
async_page: Page,
24+
) -> AsyncIterator[None]:
25+
try:
26+
yield
27+
# allows to also capture exceptions form `with pytest.raise(...)``
28+
except BaseException:
29+
path = _HERE / f"{uuid4()}.ignore.png"
30+
await async_page.screenshot(path=path)
31+
print(f"Please check :{path}")
32+
33+
raise
34+
35+
36+
async def _get_locator(
37+
async_page: Page,
38+
text: str,
39+
instances: NonNegativeInt | None,
40+
timeout: float, # noqa: ASYNC109
41+
) -> Locator:
42+
async with _take_screenshot_on_error(async_page):
43+
async for attempt in AsyncRetrying(
44+
reraise=True, wait=wait_fixed(0.1), stop=stop_after_delay(timeout)
45+
):
46+
with attempt:
47+
locator = async_page.get_by_text(text)
48+
count = await locator.count()
49+
if instances is None:
50+
assert count > 0, f"cold not find text='{text}'"
51+
else:
52+
assert (
53+
count == instances
54+
), f"found {count} instances of text='{text}'. Expected {instances}"
55+
return locator
56+
57+
58+
async def assert_contains_text(
59+
async_page: Page,
60+
text: str,
61+
instances: NonNegativeInt | None = None,
62+
timeout: float = _DEFAULT_TIMEOUT, # noqa: ASYNC109
63+
) -> None:
64+
await _get_locator(async_page, text, instances=instances, timeout=timeout)
65+
66+
67+
async def click_on_text(
68+
async_page: Page,
69+
text: str,
70+
instances: NonNegativeInt | None = None,
71+
timeout: float = _DEFAULT_TIMEOUT, # noqa: ASYNC109
72+
) -> None:
73+
locator = await _get_locator(async_page, text, instances=instances, timeout=timeout)
74+
await locator.click()
75+
76+
77+
async def assert_not_contains_text(
78+
async_page: Page,
79+
text: str,
80+
timeout: float = _DEFAULT_TIMEOUT, # noqa: ASYNC109
81+
) -> None:
82+
async with _take_screenshot_on_error(async_page):
83+
async for attempt in AsyncRetrying(
84+
reraise=True, wait=wait_fixed(0.1), stop=stop_after_delay(timeout)
85+
):
86+
with attempt:
87+
locator = async_page.get_by_text(text)
88+
assert await locator.count() < 1, f"found text='{text}' in body"

0 commit comments

Comments
 (0)