From c7c62d9458f02046bdee9da997a0b89086f9a449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 5 Feb 2026 13:33:11 +0100 Subject: [PATCH 1/8] added multiple translations scenario --- .env.example | 1 + .gitignore | 5 +++++ Makefile | 2 +- e2e_test_config.template.yml | 2 +- e2e_test_data_template.json | 1 + tests/helpers.py | 7 +++---- translations/en/LC_MESSAGES/messages.po | 7 +++++++ translations/pl/LC_MESSAGES/messages.po | 7 +++++++ 8 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bb32390 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +GOODMAP_PATH=/path/to/your/goodmap diff --git a/.gitignore b/.gitignore index 24d5735..3f6a9b2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ __pycache__/ # Compiled translation files (generated from .po files) translations/**/messages.mo +*~ + +CLAUDE.md +.playwright-mcp +.plans \ No newline at end of file diff --git a/Makefile b/Makefile index fef1252..466d92f 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ e2e-stress-tests: $(MAKE) pytest-run PYTEST_SPEC="tests/stress" run-e2e-env: - poetry --project '$(GOODMAP_PATH)' run flask --app "goodmap.goodmap:create_app(config_path='$(CONFIG_PATH)')" --debug run + poetry -C '$(GOODMAP_PATH)' run flask --app "goodmap.goodmap:create_app(config_path='$(CURDIR)/$(CONFIG_PATH)')" --debug run compile-translations: poetry run pybabel compile -d translations diff --git a/e2e_test_config.template.yml b/e2e_test_config.template.yml index 6a917d5..e73ba88 100644 --- a/e2e_test_config.template.yml +++ b/e2e_test_config.template.yml @@ -7,7 +7,7 @@ USE_WWW: False TRANSLATION_DIRECTORIES: [__E2E_TESTS_DIR__/translations] DB: TYPE: json_file - PATH: e2e_test_data.json + PATH: __E2E_TESTS_DIR__/e2e_test_data.json #TODO this should not be necessary argument LANGUAGES: diff --git a/e2e_test_data_template.json b/e2e_test_data_template.json index 3ca3e01..c3f595a 100644 --- a/e2e_test_data_template.json +++ b/e2e_test_data_template.json @@ -48,6 +48,7 @@ "str" ] ], + "reported_issue_types": ["under construction", "has a hole"], "categories": { "accessible_by": [ "bikes", diff --git a/tests/helpers.py b/tests/helpers.py index 640b24e..6eb8fde 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -144,11 +144,10 @@ def verify_problem_form(page: Page) -> None: expect(dropdown).to_be_visible() options_text = dropdown.locator("option").all_text_contents() - # Check that all problem type options exist + # Check that all problem type options exist (dynamic from backend config) expected_options = [ - "this point is not here", - "it's overloaded", - "it's broken", + "under construction", + "has a hole", "other", ] diff --git a/translations/en/LC_MESSAGES/messages.po b/translations/en/LC_MESSAGES/messages.po index 9ba5638..bcd185f 100644 --- a/translations/en/LC_MESSAGES/messages.po +++ b/translations/en/LC_MESSAGES/messages.po @@ -41,6 +41,13 @@ msgstr "big bridge" msgid "small bridge" msgstr "small bridge" +# Reported issue types +msgid "under construction" +msgstr "under construction" + +msgid "has a hole" +msgstr "has a hole" + # Help texts - category headers msgid "categories_help_accessible_by" msgstr "Who can use this bridge" diff --git a/translations/pl/LC_MESSAGES/messages.po b/translations/pl/LC_MESSAGES/messages.po index 00ca1a4..c76a5fb 100644 --- a/translations/pl/LC_MESSAGES/messages.po +++ b/translations/pl/LC_MESSAGES/messages.po @@ -41,6 +41,13 @@ msgstr "duży most" msgid "small bridge" msgstr "mały most" +# Reported issue types +msgid "under construction" +msgstr "w budowie" + +msgid "has a hole" +msgstr "ma dziurę" + # Help texts - category headers msgid "categories_help_accessible_by" msgstr "Kto może korzystać z tego mostu" From efdb1c5a449f961c7871a4ff97ba9063d7ccd8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 5 Feb 2026 14:28:23 +0100 Subject: [PATCH 2/8] added tests for share button --- tests/basic/test_left_panel.py | 23 ++-- tests/basic/test_location_buttons.py | 13 +- tests/basic/test_map.py | 16 ++- tests/basic/test_share.py | 179 +++++++++++++++++++++++++++ tests/conftest.py | 112 ++++------------- tests/stress/test_stress.py | 3 +- 6 files changed, 228 insertions(+), 118 deletions(-) create mode 100644 tests/basic/test_share.py diff --git a/tests/basic/test_left_panel.py b/tests/basic/test_left_panel.py index 65728f6..9056c9a 100644 --- a/tests/basic/test_left_panel.py +++ b/tests/basic/test_left_panel.py @@ -180,7 +180,7 @@ def test_panel_opens_on_toggle_click(self, mobile_page: Page): expect(filter_dialog).to_be_visible(timeout=5000) # Filter form should be visible inside dialog - filter_form = mobile_page.locator('#filter-form') + filter_form = mobile_page.locator("#filter-form") expect(filter_form).to_be_visible() @pytest.mark.parametrize("mobile_page", ["iphone-6"], indirect=True) @@ -318,15 +318,15 @@ def test_filter_text_not_truncated_on_tablet(self, page: Page): # Check that filter category headers are fully visible # Look for specific text that was previously truncated - panel_text = page.evaluate( - "() => document.querySelector('#left-panel').textContent" - ) + panel_text = page.evaluate("() => document.querySelector('#left-panel').textContent") # These should be fully visible, not truncated - assert "type_of_place" in panel_text or "type of place" in panel_text.lower(), \ - "type_of_place should be fully visible" - assert "accessible_by" in panel_text or "accessible by" in panel_text.lower(), \ - "accessible_by should be fully visible" + assert ( + "type_of_place" in panel_text or "type of place" in panel_text.lower() + ), "type_of_place should be fully visible" + assert ( + "accessible_by" in panel_text or "accessible by" in panel_text.lower() + ), "accessible_by should be fully visible" class TestLeftPanelFilterHelpers: @@ -376,8 +376,11 @@ def test_panel_has_custom_scrollbar_styling(self, page: Page): # Chromium uses ::-webkit-scrollbar (can't easily check via JS) # Just verify the property is set (may be empty string in Chromium) # This test mainly verifies the CSS is being applied - assert scrollbar_width in ["thin", "auto", ""], \ - f"Unexpected scrollbar-width value: {scrollbar_width}" + assert scrollbar_width in [ + "thin", + "auto", + "", + ], f"Unexpected scrollbar-width value: {scrollbar_width}" def test_offcanvas_body_has_overflow_auto(self, page: Page): """ diff --git a/tests/basic/test_location_buttons.py b/tests/basic/test_location_buttons.py index 1d32631..5877385 100644 --- a/tests/basic/test_location_buttons.py +++ b/tests/basic/test_location_buttons.py @@ -41,13 +41,11 @@ def test_buttons_respond_to_granted_permission_on_load(self, page: Page, geoloca expect(location_button).to_have_css("opacity", "1", timeout=5000) expect(location_button).to_have_css("filter", "none") - def test_buttons_show_disabled_when_permission_denied_on_load(self, browser, webpack_script): + def test_buttons_show_disabled_when_permission_denied_on_load(self, browser): """ Verify that when geolocation permission is denied/not granted, buttons show disabled state on page load. """ - from tests.conftest import WEBPACK_SCRIPT_URL - # Mock geolocation API to simulate permission denied - add at context level # so it runs before any page script geolocation_denied_script = """ @@ -76,18 +74,9 @@ def test_buttons_show_disabled_when_permission_denied_on_load(self, browser, web page = context.new_page() - # Setup webpack route interception - def handle_webpack_route(route): - route.fulfill( - status=200, - content_type="application/javascript; charset=utf-8", - body=webpack_script, - ) - def block_hmr_route(route): route.abort() - page.route(WEBPACK_SCRIPT_URL, handle_webpack_route) page.route("**/ws", block_hmr_route) page.route("**/*.hot-update.*", block_hmr_route) diff --git a/tests/basic/test_map.py b/tests/basic/test_map.py index 02f3b40..b353bfa 100644 --- a/tests/basic/test_map.py +++ b/tests/basic/test_map.py @@ -46,13 +46,17 @@ def test_should_not_have_scrollbars(self, page: Page): ) # Assert no scrollbars (scroll dimensions should not exceed viewport) - assert ( - dimensions["scrollWidth"] <= dimensions["innerWidth"] - ), f"Horizontal scrollbar detected: scrollWidth={dimensions['scrollWidth']}, innerWidth={dimensions['innerWidth']}" + assert dimensions["scrollWidth"] <= dimensions["innerWidth"], ( + f"Horizontal scrollbar detected: " + f"scrollWidth={dimensions['scrollWidth']}, " + f"innerWidth={dimensions['innerWidth']}" + ) - assert ( - dimensions["scrollHeight"] <= dimensions["innerHeight"] - ), f"Vertical scrollbar detected: scrollHeight={dimensions['scrollHeight']}, innerHeight={dimensions['innerHeight']}" + assert dimensions["scrollHeight"] <= dimensions["innerHeight"], ( + f"Vertical scrollbar detected: " + f"scrollHeight={dimensions['scrollHeight']}, " + f"innerHeight={dimensions['innerHeight']}" + ) def test_filter_checkbox_filters_markers(self, page: Page): """Verify clicking filter checkbox actually filters the markers on the map""" diff --git a/tests/basic/test_share.py b/tests/basic/test_share.py new file mode 100644 index 0000000..38754eb --- /dev/null +++ b/tests/basic/test_share.py @@ -0,0 +1,179 @@ +""" +Share Feature Tests + +Tests the share button functionality in marker popups: +- Desktop: copies a ?locationId= link to clipboard and shows a toast +- Mobile: triggers the Web Share API (navigator.share()) +- Shared link: visiting ?locationId= auto-opens the popup for that location +""" + +import pytest +from playwright.sync_api import Page, expect + +from tests.conftest import ALL_MOBILE_DEVICES, BASE_URL, MARKER_LOAD_TIMEOUT + + +class TestShareOnDesktop: + """Test suite for share button functionality on desktop""" + + def test_share_button_copies_link_to_clipboard(self, page: Page): + """ + Verify clicking the share button copies a locationId link to clipboard + and shows a toast notification. + """ + page.goto(BASE_URL, wait_until="domcontentloaded") + + # Grant clipboard permissions + page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + + # Click first marker to trigger cluster expansion + first_marker = page.locator(".leaflet-marker-icon").first + first_marker.click() + + # Wait for markers to appear (should be 2 after cluster expansion) + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(2, timeout=MARKER_LOAD_TIMEOUT) + + # Click the rightmost marker + page.evaluate( + """ + () => { + const markers = document.querySelectorAll('.leaflet-marker-icon'); + let rightmostMarker = null; + let maxX = -Infinity; + + markers.forEach(marker => { + const rect = marker.getBoundingClientRect(); + if (rect.x > maxX) { + maxX = rect.x; + rightmostMarker = marker; + } + }); + + if (rightmostMarker) { + rightmostMarker.click(); + } + } + """ + ) + + # Verify popup is visible + popup = page.locator(".leaflet-popup-content") + expect(popup).to_be_visible() + + # Click the share button + share_button = page.get_by_role("button", name="share") + expect(share_button).to_be_visible() + share_button.click() + + # Verify toast notification appears + toast = page.get_by_role("status") + expect(toast).to_contain_text("Link copied to clipboard") + + # Verify clipboard contains URL with ?locationId= + clipboard_text = page.evaluate("() => navigator.clipboard.readText()") + assert "?locationId=" in clipboard_text + + def test_shared_link_opens_popup_with_correct_content(self, page: Page): + """ + Verify navigating to a URL with ?locationId= auto-opens the popup + with the correct location content. + """ + page.goto(f"{BASE_URL}/?locationId=dattarro", wait_until="domcontentloaded") + + # Verify popup is visible + popup = page.locator(".leaflet-popup-content") + expect(popup).to_be_visible(timeout=MARKER_LOAD_TIMEOUT) + + # Verify popup shows correct location + title = popup.locator("h3") + expect(title).to_have_text("Zwierzyniecka") + + subtitle = popup.locator("p").first + expect(subtitle).to_have_text("small bridge") + + +class TestShareOnMobile: + """Test suite for share button functionality on mobile devices""" + + @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) + def test_share_button_triggers_native_share(self, mobile_page: Page, device_name: str): + """ + Verify clicking the share button triggers navigator.share() on mobile. + + Tests on all mobile devices: iphone-x, iphone-6, ipad-2, samsung-s10 + """ + # Stub navigator.share() before navigating + mobile_page.add_init_script( + """ + window.__shareArgs = []; + navigator.share = (data) => { + window.__shareArgs.push(data); + return Promise.resolve(); + }; + """ + ) + + mobile_page.goto(BASE_URL, wait_until="domcontentloaded") + + # Click first marker to expand cluster + first_marker = mobile_page.locator(".leaflet-marker-icon").first + first_marker.evaluate("el => el.click()") + + # Wait for markers to appear (should be 2 after expansion) + markers = mobile_page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(2, timeout=MARKER_LOAD_TIMEOUT) + + # Click the rightmost marker + mobile_page.evaluate( + """ + () => { + const markers = document.querySelectorAll('.leaflet-marker-icon'); + let rightmostMarker = null; + let maxX = -Infinity; + + markers.forEach(marker => { + const rect = marker.getBoundingClientRect(); + if (rect.x > maxX) { + maxX = rect.x; + rightmostMarker = marker; + } + }); + + if (rightmostMarker) { + rightmostMarker.click(); + } + } + """ + ) + + # On mobile, popup appears as Material-UI Dialog + dialog_content = mobile_page.locator(".MuiDialogContent-root") + expect(dialog_content).to_be_visible(timeout=5000) + + # Click the share button + share_button = mobile_page.get_by_role("button", name="share") + expect(share_button).to_be_visible() + share_button.evaluate("el => el.click()") + + # Verify navigator.share() was called with correct URL data + share_args = mobile_page.evaluate("() => window.__shareArgs") + assert len(share_args) > 0, "navigator.share() was not called" + assert "?locationId=" in share_args[0].get("url", "") + + @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) + def test_shared_link_opens_popup_on_mobile(self, mobile_page: Page, device_name: str): + """ + Verify navigating to a URL with ?locationId= auto-opens the popup on mobile. + + Tests on all mobile devices: iphone-x, iphone-6, ipad-2, samsung-s10 + """ + mobile_page.goto(f"{BASE_URL}/?locationId=dattarro", wait_until="domcontentloaded") + + # On mobile, popup appears as Material-UI Dialog + dialog_content = mobile_page.locator(".MuiDialogContent-root") + expect(dialog_content).to_be_visible(timeout=MARKER_LOAD_TIMEOUT) + + # Verify popup shows correct location + title = dialog_content.locator("h3") + expect(title).to_have_text("Zwierzyniecka") diff --git a/tests/conftest.py b/tests/conftest.py index 127b919..7c42198 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,17 +11,11 @@ import json from collections.abc import Callable, Generator from pathlib import Path -from urllib.error import URLError -from urllib.parse import urlparse -from urllib.request import urlopen import pytest from playwright.sync_api import BrowserContext, Page, Route # Constants -WEBPACK_SCRIPT_URL = "http://localhost:8080/index.min.js" -CACHE_DIR = Path(".playwright-cache") -CACHE_FILE = CACHE_DIR / "webpack-script.js" BASE_URL = "http://localhost:5000" # Timeouts (in milliseconds) @@ -33,19 +27,35 @@ MOBILE_DEVICES = { "iphone-x": { "viewport": {"width": 375, "height": 812}, - "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", + "user_agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) " + "AppleWebKit/604.1.38 (KHTML, like Gecko) " + "Version/11.0 Mobile/15A372 Safari/604.1" + ), }, "iphone-6": { "viewport": {"width": 375, "height": 667}, - "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", + "user_agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) " + "AppleWebKit/604.1.38 (KHTML, like Gecko) " + "Version/11.0 Mobile/15A372 Safari/604.1" + ), }, "ipad-2": { "viewport": {"width": 768, "height": 1024}, - "user_agent": "Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1", + "user_agent": ( + "Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) " + "AppleWebKit/604.1.34 (KHTML, like Gecko) " + "Version/11.0 Mobile/15A5341f Safari/604.1" + ), }, "samsung-s10": { "viewport": {"width": 360, "height": 760}, - "user_agent": "Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.2 Chrome/67.0.3396.87 Mobile Safari/537.36", + "user_agent": ( + "Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G973U) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "SamsungBrowser/9.2 Chrome/67.0.3396.87 Mobile Safari/537.36" + ), }, } @@ -68,50 +78,6 @@ } -def pytest_configure(config): - """ - Pytest hook called before test collection. - Fetches and caches the webpack script from the frontend dev server. - """ - # In xdist, run this only on master (workers share FS) - if getattr(config, "workerinput", None) is not None: - return - - # Create cache directory if it doesn't exist - CACHE_DIR.mkdir(parents=True, exist_ok=True) - - # Fetch webpack script if not cached - if not CACHE_FILE.exists(): - print(f"\nFetching webpack script from {WEBPACK_SCRIPT_URL}...") - try: - # Validate URL scheme and hostname (Ruff S310) - parsed = urlparse(WEBPACK_SCRIPT_URL) - if parsed.scheme not in {"http", "https"} or parsed.hostname not in { - "localhost", - "127.0.0.1", - }: - raise ValueError(f"Refusing non-local URL: {WEBPACK_SCRIPT_URL}") - - with urlopen(WEBPACK_SCRIPT_URL, timeout=10) as response: # noqa: S310 - script_content = response.read().decode("utf-8") - - # Validate script content - if len(script_content) < 100: - raise ValueError("Webpack script content is too short, likely invalid") - - # Save to cache (atomic write via temp file) - tmp = CACHE_FILE.with_suffix(".js.tmp") - tmp.write_text(script_content, encoding="utf-8") - tmp.replace(CACHE_FILE) - print(f"Webpack script cached to {CACHE_FILE} ({len(script_content)} bytes)") - - except (URLError, TimeoutError) as e: - print(f"WARNING: Failed to fetch webpack script: {e}") - print("Tests may fail if webpack script is required.") - else: - print(f"\nUsing cached webpack script from {CACHE_FILE}") - - @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): # noqa: ARG001 """ @@ -123,45 +89,21 @@ def pytest_runtest_makereport(item, call): # noqa: ARG001 setattr(item, f"rep_{rep.when}", rep) -@pytest.fixture(scope="session") -def webpack_script() -> str: - """ - Session-scoped fixture that loads the cached webpack script. - """ - if not CACHE_FILE.exists(): - raise FileNotFoundError( - f"Webpack script cache file not found: {CACHE_FILE}. " - "Ensure the frontend dev server is running on localhost:8080." - ) - - return CACHE_FILE.read_text() - - @pytest.fixture -def page(page: Page, webpack_script: str) -> Page: +def page(page: Page) -> Page: """ - Override the default Playwright page fixture to intercept webpack script requests. + Override the default Playwright page fixture. - This fixture automatically routes requests to the webpack script URL - and serves the cached version instead of hitting the network. Sets desktop viewport size (1280x800) for consistent desktop testing. Blocks HMR/websocket requests to prevent page refreshes during tests. """ # Set desktop viewport size page.set_viewport_size({"width": 1280, "height": 800}) - def handle_webpack_route(route: Route) -> None: - """Intercept webpack script requests and serve from cache""" - route.fulfill( - status=200, content_type="application/javascript; charset=utf-8", body=webpack_script - ) - def block_hmr_route(route: Route) -> None: """Block HMR/hot reload requests to prevent page refreshes""" route.abort() - # Setup route interception - page.route(WEBPACK_SCRIPT_URL, handle_webpack_route) # Block HMR websocket and hot update requests page.route("**/ws", block_hmr_route) page.route("**/*.hot-update.*", block_hmr_route) @@ -207,7 +149,7 @@ def get_opened_urls() -> list[str]: @pytest.fixture -def mobile_page(browser, webpack_script: str, request) -> Generator[Page, None, None]: +def mobile_page(browser, request) -> Generator[Page, None, None]: """ Fixture that creates a page with proper mobile device emulation. @@ -270,17 +212,10 @@ def test_mobile(mobile_page, device_name): # Create a page from this context page = context.new_page() - # Setup webpack route interception (same as regular page fixture) - def handle_webpack_route(route: Route) -> None: - route.fulfill( - status=200, content_type="application/javascript; charset=utf-8", body=webpack_script - ) - def block_hmr_route(route: Route) -> None: """Block HMR/hot reload requests to prevent page refreshes""" route.abort() - page.route(WEBPACK_SCRIPT_URL, handle_webpack_route) # Block HMR websocket and hot update requests page.route("**/ws", block_hmr_route) page.route("**/*.hot-update.*", block_hmr_route) @@ -447,6 +382,5 @@ def save(self, filepath: str, max_allowed_ms: int = 25000): "geolocation", "page", "performance_tracker", - "webpack_script", "window_open_stub", ] diff --git a/tests/stress/test_stress.py b/tests/stress/test_stress.py index b3eefd4..2613545 100644 --- a/tests/stress/test_stress.py +++ b/tests/stress/test_stress.py @@ -88,7 +88,8 @@ def test_should_load_all_markers_and_measure_performance(self, page: Page, perfo elapsed_ms = (end_time - start_time) * 1000 print( - f"Run {run_number} took {elapsed_ms:.0f}ms and loaded {marker_count} markers/clusters" + f"Run {run_number} took {elapsed_ms:.0f}ms " + f"and loaded {marker_count} markers/clusters" ) # Record performance data From 262cae372e91fb593ac4681d90bb4c552975b473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 5 Feb 2026 22:30:42 +0100 Subject: [PATCH 3/8] fixed data --- e2e_test_data_template.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e_test_data_template.json b/e2e_test_data_template.json index c3f595a..d9ff907 100644 --- a/e2e_test_data_template.json +++ b/e2e_test_data_template.json @@ -48,7 +48,7 @@ "str" ] ], - "reported_issue_types": ["under construction", "has a hole"], + "reported_issue_types": ["under construction", "has a hole", "other"], "categories": { "accessible_by": [ "bikes", From ae458553c78fc42034cd4405ada958d85637a015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 5 Feb 2026 23:02:04 +0100 Subject: [PATCH 4/8] cleanup --- tests/conftest.py | 240 ++++++++-------------------------------------- 1 file changed, 39 insertions(+), 201 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7c42198..029e4e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ """ Pytest fixtures and configuration for Playwright E2E tests. -This module provides custom fixtures for: +Provides custom fixtures for: - Webpack script caching and interception - Window.open() stub tracking - Geolocation mocking @@ -13,17 +13,13 @@ from pathlib import Path import pytest -from playwright.sync_api import BrowserContext, Page, Route +from playwright.sync_api import BrowserContext, Page -# Constants BASE_URL = "http://localhost:5000" -# Timeouts (in milliseconds) -MAP_LOAD_TIMEOUT = 5000 MARKER_LOAD_TIMEOUT = 5000 TABLE_LOAD_TIMEOUT = 5000 -# Mobile device configurations for Playwright MOBILE_DEVICES = { "iphone-x": { "viewport": {"width": 375, "height": 812}, @@ -59,34 +55,39 @@ }, } -# Device lists for parametrized tests ALL_MOBILE_DEVICES = list(MOBILE_DEVICES.keys()) -# UI alignment tolerance (in pixels) UI_VERTICAL_ALIGNMENT_TOLERANCE = 3 -# Test locations TEST_LOCATIONS = { "RYSY_MOUNTAIN": { "lat": 49.179, "lon": 20.088, - # Tile pattern matches zoom 14-16 for Rysy Mountain area - # Zoom 16: 36424/22456, Zoom 15: 18211-18212/11227-11228, Zoom 14: 9105-9106/5613-5614 "tile_pattern": r"https://[abc]\.tile\.openstreetmap\.org/1[456]/\d+/\d+\.png", }, "WROCLAW_CENTER": {"lat": 51.10655, "lon": 17.0555}, } -@pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_makereport(item, call): # noqa: ARG001 - """ - Hook to store test result on the item for use in fixtures. - Used by mobile_page fixture to determine if video should be retained. +def _block_hmr(page: Page) -> None: + """Block HMR/hot reload requests to prevent page refreshes during tests.""" + page.route("**/ws", lambda route: route.abort()) + page.route("**/*.hot-update.*", lambda route: route.abort()) + + +def _stub_window_open(page: Page) -> Callable[[], list[str]]: + """Stub window.open() and return a callable that retrieves opened URLs.""" + opened_urls = [] + page.expose_function("__captureWindowOpen", lambda url: opened_urls.append(url)) + page.add_init_script( + """ + window.open = function(url, target, features) { + window.__captureWindowOpen(url); + return null; + }; """ - outcome = yield - rep = outcome.get_result() - setattr(item, f"rep_{rep.when}", rep) + ) + return lambda: opened_urls.copy() @pytest.fixture @@ -97,77 +98,33 @@ def page(page: Page) -> Page: Sets desktop viewport size (1280x800) for consistent desktop testing. Blocks HMR/websocket requests to prevent page refreshes during tests. """ - # Set desktop viewport size page.set_viewport_size({"width": 1280, "height": 800}) - - def block_hmr_route(route: Route) -> None: - """Block HMR/hot reload requests to prevent page refreshes""" - route.abort() - - # Block HMR websocket and hot update requests - page.route("**/ws", block_hmr_route) - page.route("**/*.hot-update.*", block_hmr_route) - + _block_hmr(page) return page @pytest.fixture def window_open_stub(page: Page) -> Callable[[], list[str]]: """ - Fixture that stubs window.open() and tracks all opened URLs. - - Returns: - A callable that returns the list of URLs opened via window.open() - - Example: - def test_popup_opens_link(page, window_open_stub): - page.goto(BASE_URL) - page.click("text=Open Link") - opened_urls = window_open_stub() - assert len(opened_urls) == 1 - assert "google.com" in opened_urls[0] - """ - opened_urls = [] + Stub window.open() and track all opened URLs. - # Expose a function to capture window.open calls - page.expose_function("__captureWindowOpen", lambda url: opened_urls.append(url)) - - # Stub window.open in the page context - page.add_init_script( - """ - window.open = function(url, target, features) { - window.__captureWindowOpen(url); - return null; // Prevent actual window opening - }; + Returns a callable that returns the list of URLs opened via window.open(). """ - ) - - def get_opened_urls() -> list[str]: - return opened_urls.copy() - - return get_opened_urls + return _stub_window_open(page) @pytest.fixture def mobile_page(browser, request) -> Generator[Page, None, None]: """ - Fixture that creates a page with proper mobile device emulation. + Create a page with proper mobile device emulation. - This fixture creates a new browser context with the correct user agent, + Creates a new browser context with the correct user agent, ensuring that react-device-detect properly identifies the device as mobile. Supports two parametrization styles: - 1. Indirect (preferred): - @pytest.mark.parametrize("mobile_page", ALL_MOBILE_DEVICES, indirect=True) - def test_mobile(mobile_page): - ... - - 2. Legacy (device_name parameter): - @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) - def test_mobile(mobile_page, device_name): - ... + 1. Indirect: @pytest.mark.parametrize("mobile_page", ALL_MOBILE_DEVICES, indirect=True) + 2. Legacy: @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) """ - # Support both indirect parametrization and legacy device_name parameter if hasattr(request, "param"): device_name = request.param else: @@ -178,141 +135,53 @@ def test_mobile(mobile_page, device_name): if not device_name: raise ValueError("mobile_page fixture requires 'device_name' parameter") - try: - device_config = MOBILE_DEVICES[device_name] - except KeyError as e: - raise ValueError( - f"Unknown device_name={device_name}. Expected one of: {list(MOBILE_DEVICES)}" - ) from e - - # Determine video recording settings from pytest config - video_option = request.config.getoption("--video", default="off") - record_video_dir = None - record_video_size = None - if video_option in ("on", "retain-on-failure"): - output_dir = request.config.getoption("--output", default="test-results") - # Create a unique directory for this test's video using nodeid to prevent collisions - nodeid = request.node.nodeid - safe_nodeid = ( - nodeid.replace("::", "__").replace("/", "__").replace("[", "-").replace("]", "") - ) - record_video_dir = str(Path(output_dir) / safe_nodeid) - # Set video size to match mobile viewport for clearer recordings - record_video_size = device_config["viewport"] - - # Create a new context with mobile user agent, touch support, and optional video recording + device_config = MOBILE_DEVICES[device_name] + context = browser.new_context( viewport=device_config["viewport"], user_agent=device_config["user_agent"], has_touch=True, - record_video_dir=record_video_dir, - record_video_size=record_video_size, ) - - # Create a page from this context page = context.new_page() - - def block_hmr_route(route: Route) -> None: - """Block HMR/hot reload requests to prevent page refreshes""" - route.abort() - - # Block HMR websocket and hot update requests - page.route("**/ws", block_hmr_route) - page.route("**/*.hot-update.*", block_hmr_route) + _block_hmr(page) yield page - # Get video path before closing (video is saved on close) - video = page.video - video_path = video.path() if video else None - - # Cleanup page.close() context.close() - # Handle retain-on-failure: delete video if test passed - if video_path and video_option == "retain-on-failure": - # Check if any test phase failed (setup, call, or teardown) - rep_setup = getattr(request.node, "rep_setup", None) - rep_call = getattr(request.node, "rep_call", None) - rep_teardown = getattr(request.node, "rep_teardown", None) - test_failed = any(rep and rep.failed for rep in (rep_setup, rep_call, rep_teardown)) - if not test_failed and Path(video_path).exists(): - Path(video_path).unlink() - # Remove empty directory - video_dir = Path(video_path).parent - if video_dir.exists() and not any(video_dir.iterdir()): - video_dir.rmdir() - @pytest.fixture def mobile_window_open_stub(mobile_page: Page) -> Callable[[], list[str]]: """ - Fixture that stubs window.open() for mobile pages and tracks all opened URLs. - Same as window_open_stub but works with mobile_page fixture. - """ - opened_urls = [] - - # Expose a function to capture window.open calls - mobile_page.expose_function("__captureWindowOpen", lambda url: opened_urls.append(url)) + Stub window.open() for mobile pages and track all opened URLs. - # Stub window.open in the page context - mobile_page.add_init_script( - """ - window.open = function(url, target, features) { - window.__captureWindowOpen(url); - return null; // Prevent actual window opening - }; + Same as window_open_stub but works with the mobile_page fixture. """ - ) - - def get_opened_urls() -> list[str]: - return opened_urls.copy() - - return get_opened_urls + return _stub_window_open(mobile_page) @pytest.fixture def geolocation(context: BrowserContext) -> Callable[[float, float], None]: """ - Fixture that provides a function to set geolocation. - - Returns: - A callable that sets the geolocation to the given lat/lon coordinates. + Provide a function to set browser geolocation. - Example: - def test_my_location(page, geolocation): - geolocation(50.0614, 19.9365) # Krakow coordinates - page.goto(BASE_URL) - # ... test location-based functionality + Returns a callable that sets the geolocation to the given lat/lon coordinates. """ - # Grant geolocation permissions context.grant_permissions(["geolocation"]) def set_location(latitude: float, longitude: float) -> None: - """Set the browser geolocation""" context.set_geolocation({"latitude": latitude, "longitude": longitude}) return set_location @pytest.fixture -def performance_tracker() -> Callable: +def performance_tracker(): """ - Fixture for tracking performance metrics in stress tests. - - Returns: - An object with methods to measure and retrieve run times. + Track performance metrics for stress tests. - Example: - def test_stress(page, performance_tracker): - for i in range(5): - start_time = time.time() - # ... perform test actions - elapsed_ms = (time.time() - start_time) * 1000 - performance_tracker.add_run(i + 1, elapsed_ms, marker_count) - - performance_tracker.save("test-results/stress-test-perf.json") + Returns an object with methods to add_run(), calculate_stats(), and save() results. """ class PerformanceTracker: @@ -322,14 +191,12 @@ def __init__(self): self.expected_runs = 0 def add_run(self, run_number: int, time_ms: float, markers: int): - """Add a performance measurement""" self.run_times.append( {"run": run_number, "time": round(time_ms, 2), "markers": markers} ) self.num_runs += 1 def calculate_stats(self, max_allowed_ms: int = 25000): - """Calculate performance statistics""" if not self.run_times: return {} @@ -349,38 +216,9 @@ def calculate_stats(self, max_allowed_ms: int = 25000): } def save(self, filepath: str, max_allowed_ms: int = 25000): - """Save performance data to JSON file""" stats = self.calculate_stats(max_allowed_ms) - - # Ensure directory exists Path(filepath).parent.mkdir(parents=True, exist_ok=True) - with open(filepath, "w") as f: json.dump(stats, f, indent=2) - print(f"\nPerformance data saved to {filepath}") - if stats: - print(f"Average time: {stats['avgTime']}ms") - print(f"Max time: {stats['maxTime']}ms") - print(f"Passed: {stats['passed']}") - else: - print("No runs recorded.") - return PerformanceTracker() - - -# Export constants for use in tests -__all__ = [ - "ALL_MOBILE_DEVICES", - "BASE_URL", - "MAP_LOAD_TIMEOUT", - "MARKER_LOAD_TIMEOUT", - "MOBILE_DEVICES", - "TABLE_LOAD_TIMEOUT", - "TEST_LOCATIONS", - "UI_VERTICAL_ALIGNMENT_TOLERANCE", - "geolocation", - "page", - "performance_tracker", - "window_open_stub", -] From 4090b9b1a10170e78d7e2b8bed08a43c9466ce51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 5 Feb 2026 23:20:35 +0100 Subject: [PATCH 5/8] revert this run-e2e-env --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 466d92f..fef1252 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ e2e-stress-tests: $(MAKE) pytest-run PYTEST_SPEC="tests/stress" run-e2e-env: - poetry -C '$(GOODMAP_PATH)' run flask --app "goodmap.goodmap:create_app(config_path='$(CURDIR)/$(CONFIG_PATH)')" --debug run + poetry --project '$(GOODMAP_PATH)' run flask --app "goodmap.goodmap:create_app(config_path='$(CONFIG_PATH)')" --debug run compile-translations: poetry run pybabel compile -d translations From 91ffef337f13c86b1ea6ef857025e1577aacafab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 5 Feb 2026 23:21:58 +0100 Subject: [PATCH 6/8] prettified template --- e2e_test_data_template.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e_test_data_template.json b/e2e_test_data_template.json index d9ff907..9cb54de 100644 --- a/e2e_test_data_template.json +++ b/e2e_test_data_template.json @@ -48,7 +48,11 @@ "str" ] ], - "reported_issue_types": ["under construction", "has a hole", "other"], + "reported_issue_types": [ + "under construction", + "has a hole", + "other" + ], "categories": { "accessible_by": [ "bikes", From 61ef37b36466588cb498e3f048d7ab8448a967f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 6 Feb 2026 00:29:47 +0100 Subject: [PATCH 7/8] extended tests coverage --- Makefile | 4 ++++ tests/stress/test_stress.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index fef1252..8ec89b4 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ -include .env CONFIG_PATH ?= e2e_test_config.yml +STRESS_CONFIG_PATH ?= e2e_stress_test_config.yml GOODMAP_PATH ?= . PYTEST_SPEC ?= tests/ @@ -33,6 +34,9 @@ e2e-stress-tests: run-e2e-env: poetry --project '$(GOODMAP_PATH)' run flask --app "goodmap.goodmap:create_app(config_path='$(CONFIG_PATH)')" --debug run +run-e2e-stress-env: + poetry --project '$(GOODMAP_PATH)' run flask --app "goodmap.goodmap:create_app(config_path='$(STRESS_CONFIG_PATH)')" --debug run + compile-translations: poetry run pybabel compile -d translations diff --git a/tests/stress/test_stress.py b/tests/stress/test_stress.py index 2613545..864592d 100644 --- a/tests/stress/test_stress.py +++ b/tests/stress/test_stress.py @@ -9,7 +9,7 @@ from playwright.sync_api import Page, expect -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT class TestStress: @@ -100,6 +100,36 @@ def test_should_load_all_markers_and_measure_performance(self, page: Page, perfo marker_count >= min_expected_markers ), f"Expected at least {min_expected_markers} markers but got {marker_count}" + # Click clusters until individual markers appear, then click a marker + clusters = page.locator(".marker-cluster") + individual_markers = page.locator(".leaflet-marker-icon:not(.marker-cluster)") + popup = page.locator(".leaflet-popup-content") + max_clicks = 20 + for i in range(max_clicks): + if individual_markers.count() > 0: + break + clusters.first.click() + print(f"Click {i + 1}: expanding cluster...") + expect(page.locator(".leaflet-marker-icon").first).to_be_visible( + timeout=MARKER_LOAD_TIMEOUT + ) + else: + raise AssertionError( + f"No individual markers appeared after {max_clicks} cluster clicks" + ) + + # Click an individual marker to open its popup + individual_markers.first.click() + expect(popup).to_be_visible(timeout=MARKER_LOAD_TIMEOUT) + + # Wait for content to load (popup initially shows "Loading...") + title = popup.locator("h3") + expect(title).to_be_visible(timeout=MARKER_LOAD_TIMEOUT) + assert title.text_content(), "Popup title should not be empty" + expect(popup.get_by_text("type_of_place").first).to_be_visible() + expect(popup.get_by_text("accessible_by").first).to_be_visible() + print(f"Popup verified for: {title.text_content()}") + # Save performance data to JSON file performance_tracker.save("test-results/stress-test-perf.json", max_allowed_time_ms) From a0fb3053b548ef3b8347472a026194e1bc391861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 6 Feb 2026 00:43:54 +0100 Subject: [PATCH 8/8] guard added for test --- tests/stress/test_stress.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/stress/test_stress.py b/tests/stress/test_stress.py index 864592d..194ebfc 100644 --- a/tests/stress/test_stress.py +++ b/tests/stress/test_stress.py @@ -108,6 +108,8 @@ def test_should_load_all_markers_and_measure_performance(self, page: Page, perfo for i in range(max_clicks): if individual_markers.count() > 0: break + if clusters.count() == 0: + raise AssertionError("No clusters or individual markers found to click") clusters.first.click() print(f"Click {i + 1}: expanding cluster...") expect(page.locator(".leaflet-marker-icon").first).to_be_visible(