From 584ccec8634ae0c843a68d3359c5abd33ddda0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sun, 18 Jan 2026 00:56:37 +0100 Subject: [PATCH 01/12] Revert "fix: remove webpack overlay and add touch support to mobile tests (#36)" This reverts commit 91e881eb2dc7b0dee12f22adc9d2128e93443f5f. --- tests/basic/test_location_buttons.py | 8 +++----- tests/conftest.py | 6 ++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/basic/test_location_buttons.py b/tests/basic/test_location_buttons.py index 2d7e264..bc9e977 100644 --- a/tests/basic/test_location_buttons.py +++ b/tests/basic/test_location_buttons.py @@ -218,9 +218,8 @@ def test_list_view_shows_tooltip_on_tap(self, mobile_page: Page, device_name: st mobile_page.goto(BASE_URL, wait_until="domcontentloaded") # Tap the list view button - # Use force=True to bypass webpack overlay that may intercept clicks on CI list_view_button = mobile_page.locator("#listViewButton") - list_view_button.click(force=True) + list_view_button.click() # Check tooltip appears with disabled message tooltip = mobile_page.locator('[role="tooltip"]') @@ -243,9 +242,8 @@ def test_all_buttons_show_tooltip_on_tap(self, mobile_page: Page, device_name: s for selector, _name in buttons: # Tap the button - # Use force=True to bypass webpack overlay that may intercept clicks on CI button = mobile_page.locator(selector) - button.click(force=True) + button.click() # Check tooltip appears tooltip = mobile_page.locator('[role="tooltip"]') @@ -253,5 +251,5 @@ def test_all_buttons_show_tooltip_on_tap(self, mobile_page: Page, device_name: s expect(tooltip).to_contain_text("Location services are disabled") # Click elsewhere to dismiss tooltip and wait for it to disappear - mobile_page.locator("body").click(position={"x": 10, "y": 10}, force=True) + mobile_page.locator("body").click(position={"x": 10, "y": 10}) expect(tooltip).not_to_be_visible(timeout=2000) diff --git a/tests/conftest.py b/tests/conftest.py index e226e16..ee927c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -262,11 +262,9 @@ def test_mobile(mobile_page, device_name): f"Unknown device_name={device_name}. Expected one of: {list(MOBILE_DEVICES)}" ) from e - # Create a new context with mobile user agent and touch support + # Create a new context with mobile user agent context = browser.new_context( - viewport=device_config["viewport"], - user_agent=device_config["user_agent"], - has_touch=True, + viewport=device_config["viewport"], user_agent=device_config["user_agent"] ) # Create a page from this context From 4087b371e630aaa9745583733f2b1429fe441ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sun, 18 Jan 2026 00:58:44 +0100 Subject: [PATCH 02/12] fix: serve-prod introduced --- .github/workflows/e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 26ab386..98213b5 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -73,7 +73,7 @@ jobs: - name: Start frontend server working-directory: goodmap-frontend run: | - make serve & + make serve-prod & FRONTEND_PID=$! echo $FRONTEND_PID > /tmp/frontend.pid disown $FRONTEND_PID From c3e38235dbed5cc19e898a9aa1e993c037092973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sun, 18 Jan 2026 01:15:07 +0100 Subject: [PATCH 03/12] fix inex --- e2e_test_config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e_test_config.yml b/e2e_test_config.yml index a012431..3a0223b 100644 --- a/e2e_test_config.yml +++ b/e2e_test_config.yml @@ -27,4 +27,4 @@ FEATURE_FLAGS: SHOW_SUGGEST_NEW_POINT_BUTTON: True -GOODMAP_FRONTEND_LIB_URL: "http://localhost:8080/index.js" +GOODMAP_FRONTEND_LIB_URL: "http://localhost:8080/index.min.js" From 182d1ee53671522a2b1d420c78c742881115d6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sun, 18 Jan 2026 01:37:30 +0100 Subject: [PATCH 04/12] fix deps --- e2e_stress_test_config.yml | 2 +- tests/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e_stress_test_config.yml b/e2e_stress_test_config.yml index 952cda6..2883a69 100644 --- a/e2e_stress_test_config.yml +++ b/e2e_stress_test_config.yml @@ -24,4 +24,4 @@ FEATURE_FLAGS: #GOODMAP_FRONTEND_LIB_URL: "https://cdn.jsdelivr.net/npm/@problematy/goodmap@0.4.4" -GOODMAP_FRONTEND_LIB_URL: "http://localhost:8080/index.js" +GOODMAP_FRONTEND_LIB_URL: "http://localhost:8080/index.min.js" diff --git a/tests/conftest.py b/tests/conftest.py index ee927c3..5b609ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,7 @@ from playwright.sync_api import BrowserContext, Page, Route # Constants -WEBPACK_SCRIPT_URL = "http://localhost:8080/index.js" +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" From f464a2f24896d8cd2d265c9d66acb2d736667340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Tue, 20 Jan 2026 21:43:30 +0100 Subject: [PATCH 05/12] feat added test of lef panel --- tests/basic/test_left_panel.py | 387 +++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 tests/basic/test_left_panel.py diff --git a/tests/basic/test_left_panel.py b/tests/basic/test_left_panel.py new file mode 100644 index 0000000..1034896 --- /dev/null +++ b/tests/basic/test_left_panel.py @@ -0,0 +1,387 @@ +""" +Left Panel (Filter Sidebar) Tests + +Tests the left panel (filter sidebar) functionality across different viewport sizes: +- Desktop (≥992px): Panel displays inline with fixed 220px width +- Tablet (768px-992px): Panel is offcanvas overlay, 80vw width +- Mobile (<768px): Panel is offcanvas overlay, 80vw width + +These tests verify: +- Panel visibility and positioning +- Panel scrolling when content overflows +- No page-level scrollbar (only panel scrolls) +- Close button styling and functionality on mobile/tablet +- Filter text is not truncated +""" + +import pytest +from playwright.sync_api import Page, expect + +from tests.conftest import ALL_MOBILE_DEVICES, BASE_URL, MOBILE_DEVICES + + +class TestLeftPanelDesktop: + """Test suite for left panel on desktop viewport (≥992px)""" + + def test_panel_is_visible_inline_on_desktop(self, page: Page): + """ + On desktop, the left panel should be visible inline (not as overlay) + without needing to click a toggle button. + """ + page.set_viewport_size({"width": 1200, "height": 800}) + page.goto(BASE_URL, wait_until="domcontentloaded") + + # Wait for filter categories to load + page.wait_for_selector("#filter-form", timeout=10000) + + # Panel should be visible + panel = page.locator("#left-panel") + expect(panel).to_be_visible() + + # Panel should have position: relative on desktop (inline, not overlay) + position = panel.evaluate("el => getComputedStyle(el).position") + assert position == "relative", f"Expected position: relative, got: {position}" + + def test_panel_has_fixed_width_on_desktop(self, page: Page): + """ + On desktop, the panel should have a fixed 220px width. + """ + page.set_viewport_size({"width": 1200, "height": 800}) + page.goto(BASE_URL, wait_until="domcontentloaded") + + page.wait_for_selector("#filter-form", timeout=10000) + + panel = page.locator("#left-panel") + width = panel.evaluate("el => el.clientWidth") + + # Width should be 220px (allow small tolerance for borders/scrollbar) + assert 215 <= width <= 230, f"Expected panel width ~220px, got: {width}px" + + def test_no_page_scrollbar_on_desktop(self, page: Page): + """ + The page/body should have overflow: hidden to prevent page-level scrollbar. + Only the filter panel should scroll, not the whole page. + """ + page.set_viewport_size({"width": 1200, "height": 800}) + page.goto(BASE_URL, wait_until="domcontentloaded") + + page.wait_for_selector("#filter-form", timeout=10000) + + # Check body overflow is hidden + body_overflow = page.evaluate("() => getComputedStyle(document.body).overflow") + assert body_overflow == "hidden", f"Expected body overflow: hidden, got: {body_overflow}" + + # Check that body doesn't have vertical scroll + has_scroll = page.evaluate( + "() => document.body.scrollHeight > document.body.clientHeight" + ) + assert not has_scroll, "Page should not have vertical scroll" + + def test_panel_content_scrolls_on_desktop(self, page: Page): + """ + When panel content exceeds the available height, the panel body + should be scrollable to access all filter categories. + """ + # Use smaller viewport height to ensure content overflows + page.set_viewport_size({"width": 1200, "height": 600}) + page.goto(BASE_URL, wait_until="domcontentloaded") + + page.wait_for_selector("#filter-form", timeout=10000) + + # Get panel body + panel_body = page.locator("#left-panel .offcanvas-body") + + # Check if content overflows (scrollHeight > clientHeight) + scroll_info = panel_body.evaluate( + """el => ({ + clientHeight: el.clientHeight, + scrollHeight: el.scrollHeight, + hasOverflow: el.scrollHeight > el.clientHeight + })""" + ) + + # If there's overflow, verify scrolling works + if scroll_info["hasOverflow"]: + # Get initial scroll position + initial_scroll = panel_body.evaluate("el => el.scrollTop") + + # Scroll down + panel_body.evaluate("el => el.scrollBy(0, 200)") + + # Verify scroll position changed + new_scroll = panel_body.evaluate("el => el.scrollTop") + assert new_scroll > initial_scroll, "Panel body should be scrollable" + + def test_all_filter_categories_accessible_on_desktop(self, page: Page): + """ + All filter categories (types, gender, condition, type_of_place, transparency) + should be accessible, either visible or reachable by scrolling. + """ + page.set_viewport_size({"width": 1200, "height": 600}) + page.goto(BASE_URL, wait_until="domcontentloaded") + + page.wait_for_selector("#filter-form", timeout=10000) + + # Scroll to bottom of panel to ensure all content is accessible + panel_body = page.locator("#left-panel .offcanvas-body") + panel_body.evaluate("el => el.scrollTop = el.scrollHeight") + + # Check that transparency category (typically last) is visible after scrolling + # Look for the category header text + transparency_visible = page.evaluate( + """() => { + const panel = document.querySelector('#left-panel .offcanvas-body'); + const text = panel.textContent.toLowerCase(); + return text.includes('transparency') || text.includes('high') || text.includes('low'); + }""" + ) + assert transparency_visible, "Transparency category should be accessible after scrolling" + + +class TestLeftPanelMobile: + """Test suite for left panel on mobile viewport (<992px)""" + + @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) + def test_panel_hidden_by_default_on_mobile(self, mobile_page: Page, device_name: str): + """ + On mobile, the left panel should be hidden by default (offcanvas). + """ + mobile_page.goto(BASE_URL, wait_until="domcontentloaded") + + # Wait for page to load + mobile_page.wait_for_load_state("networkidle") + + # Panel should not be visually active (offcanvas is hidden) + panel = mobile_page.locator("#left-panel") + + # Check that panel doesn't have 'show' class (Bootstrap offcanvas) + has_show_class = panel.evaluate("el => el.classList.contains('show')") + assert not has_show_class, "Panel should not have 'show' class by default on mobile" + + @pytest.mark.parametrize("device_name", ["iphone-6"]) + def test_panel_opens_on_toggle_click(self, mobile_page: Page, device_name: str): + """ + Clicking the toggle button should open the left panel as an offcanvas overlay. + """ + mobile_page.goto(BASE_URL, wait_until="domcontentloaded") + + # Find and click the toggle button + toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') + expect(toggle_button).to_be_visible() + toggle_button.click() + + # Wait for panel to be visible + panel = mobile_page.locator("#left-panel") + expect(panel).to_be_visible(timeout=5000) + + # Panel should have 'show' class when open + has_show_class = panel.evaluate("el => el.classList.contains('show')") + assert has_show_class, "Panel should have 'show' class when open" + + @pytest.mark.parametrize("device_name", ["iphone-6"]) + def test_close_button_visible_on_mobile(self, mobile_page: Page, device_name: str): + """ + On mobile, the panel should have a visible close button (X). + """ + mobile_page.goto(BASE_URL, wait_until="domcontentloaded") + + # Open the panel + toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') + toggle_button.click() + + # Wait for panel to open + mobile_page.wait_for_selector("#left-panel.show", timeout=5000) + + # Close button should be visible + close_button = mobile_page.locator("#left-panel .btn-close") + expect(close_button).to_be_visible() + + @pytest.mark.parametrize("device_name", ["iphone-6"]) + def test_close_button_closes_panel(self, mobile_page: Page, device_name: str): + """ + Clicking the close button should close the panel. + """ + mobile_page.goto(BASE_URL, wait_until="domcontentloaded") + + # Open the panel + toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') + toggle_button.click() + + # Wait for panel to open + mobile_page.wait_for_selector("#left-panel.show", timeout=5000) + + # Click close button + close_button = mobile_page.locator("#left-panel .btn-close") + close_button.click() + + # Wait for panel to close + mobile_page.wait_for_selector("#left-panel:not(.show)", timeout=5000) + + # Verify panel is closed + panel = mobile_page.locator("#left-panel") + has_show_class = panel.evaluate("el => el.classList.contains('show')") + assert not has_show_class, "Panel should be closed after clicking close button" + + @pytest.mark.parametrize("device_name", ["iphone-6"]) + def test_panel_width_on_mobile(self, mobile_page: Page, device_name: str): + """ + On mobile, the panel should be 80vw wide (80% of viewport width). + """ + mobile_page.goto(BASE_URL, wait_until="domcontentloaded") + + # Open the panel + toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') + toggle_button.click() + + # Wait for panel to open + mobile_page.wait_for_selector("#left-panel.show", timeout=5000) + + # Get panel width and viewport width + widths = mobile_page.evaluate( + """() => ({ + panelWidth: document.querySelector('#left-panel').clientWidth, + viewportWidth: window.innerWidth + })""" + ) + + expected_width = widths["viewportWidth"] * 0.8 + actual_width = widths["panelWidth"] + + # Allow 10% tolerance + assert ( + expected_width * 0.9 <= actual_width <= expected_width * 1.1 + ), f"Expected panel width ~{expected_width}px (80vw), got: {actual_width}px" + + @pytest.mark.parametrize("device_name", ["iphone-6"]) + def test_no_page_scrollbar_on_mobile(self, mobile_page: Page, device_name: str): + """ + On mobile, there should be no page-level scrollbar. + """ + mobile_page.goto(BASE_URL, wait_until="domcontentloaded") + + # Open the panel + toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') + toggle_button.click() + + # Wait for panel to open and content to load + mobile_page.wait_for_selector("#left-panel.show", timeout=5000) + mobile_page.wait_for_selector("#filter-form", timeout=10000) + + # Check body overflow is hidden + body_overflow = mobile_page.evaluate("() => getComputedStyle(document.body).overflow") + assert body_overflow == "hidden", f"Expected body overflow: hidden, got: {body_overflow}" + + +class TestLeftPanelTablet: + """Test suite for left panel on tablet viewport (768px-992px)""" + + def test_panel_is_offcanvas_on_tablet(self, page: Page): + """ + On tablet (768px width), the panel should behave as offcanvas (like mobile). + """ + page.set_viewport_size({"width": 768, "height": 1024}) + page.goto(BASE_URL, wait_until="domcontentloaded") + + # Panel should not be visible by default + panel = page.locator("#left-panel") + + # Check that panel doesn't have 'show' class + has_show_class = panel.evaluate("el => el.classList.contains('show')") + assert not has_show_class, "Panel should be hidden by default on tablet" + + # Toggle button should be visible + toggle_button = page.locator('button[aria-label="Toggle left panel"]') + expect(toggle_button).to_be_visible() + + def test_filter_text_not_truncated_on_tablet(self, page: Page): + """ + On tablet, all filter text should be fully visible without truncation. + Previously there was an issue where text like "type_of_place" was truncated. + """ + page.set_viewport_size({"width": 768, "height": 1024}) + page.goto(BASE_URL, wait_until="domcontentloaded") + + # Open the panel + toggle_button = page.locator('button[aria-label="Toggle left panel"]') + toggle_button.click() + + # Wait for panel to open and content to load + page.wait_for_selector("#left-panel.show", timeout=5000) + page.wait_for_selector("#filter-form", timeout=10000) + + # 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" + ) + + # 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 "parcel_machine" in panel_text or "parcel machine" in panel_text.lower(), \ + "parcel_machine should be fully visible" + + +class TestLeftPanelFilterHelpers: + """Test suite for filter option helper icons/tooltips""" + + def test_small_bridge_filter_has_helper_tooltip(self, page: Page): + """ + Verify that the 'small bridge' filter option in type_of_place category + has a helper icon that shows a tooltip on hover. + """ + page.set_viewport_size({"width": 1200, "height": 800}) + page.goto(BASE_URL, wait_until="domcontentloaded") + + # Wait for filter form to load + page.wait_for_selector("#filter-form", timeout=10000) + + # Find and hover over the help icon for small bridge + help_icon = page.get_by_label("Help: categories_options_help_small bridge") + expect(help_icon).to_be_visible() + help_icon.hover() + + # Verify tooltip appears + tooltip = page.locator('[role="tooltip"]') + expect(tooltip).to_be_visible() + expect(tooltip).to_contain_text("categories_options_help_small bridge") + + +class TestLeftPanelScrollbar: + """Test suite for panel scrollbar styling""" + + def test_panel_has_custom_scrollbar_styling(self, page: Page): + """ + The panel should have custom scrollbar styling (thin, semi-transparent). + """ + page.set_viewport_size({"width": 1200, "height": 600}) + page.goto(BASE_URL, wait_until="domcontentloaded") + + page.wait_for_selector("#filter-form", timeout=10000) + + # Check scrollbar-width CSS property + scrollbar_width = page.evaluate( + "() => getComputedStyle(document.querySelector('#left-panel')).scrollbarWidth" + ) + + # Firefox uses scrollbar-width property + # 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}" + + def test_offcanvas_body_has_overflow_auto(self, page: Page): + """ + The offcanvas-body should have overflow-y: auto for scrolling. + """ + page.set_viewport_size({"width": 1200, "height": 600}) + page.goto(BASE_URL, wait_until="domcontentloaded") + + page.wait_for_selector("#filter-form", timeout=10000) + + overflow_y = page.evaluate( + "() => getComputedStyle(document.querySelector('#left-panel .offcanvas-body')).overflowY" + ) + + assert overflow_y == "auto", f"Expected overflow-y: auto, got: {overflow_y}" From dc0e80b30b986cc3b4681a611f81da43e56e6b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Tue, 20 Jan 2026 23:45:40 +0100 Subject: [PATCH 06/12] fix: video enabled --- .github/workflows/e2e-tests.yml | 9 ++++++ pyproject.toml | 3 ++ tests/basic/test_location_buttons.py | 13 +++++--- tests/conftest.py | 45 ++++++++++++++++++++++++++-- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 98213b5..d127bcd 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -200,6 +200,15 @@ jobs: retention-days: 1 if-no-files-found: ignore + - name: Upload test videos on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-failure-videos + path: e2e-tests/test-results/**/*.webm + retention-days: 7 + if-no-files-found: ignore + - name: Stop frontend server if: always() run: | diff --git a/pyproject.toml b/pyproject.toml index db248a8..857ae20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ python_functions = "test_*" # Playwright-specific settings base_url = "http://localhost:5000" # Browser/headed/slowmo should be passed via CLI: --browser=chromium --headed --slowmo=100 +# Video recording: retain-on-failure keeps videos only for failed tests +# Videos are saved to test-results/ directory +addopts = "--video=retain-on-failure --output=test-results" [tool.ruff] line-length = 100 diff --git a/tests/basic/test_location_buttons.py b/tests/basic/test_location_buttons.py index bc9e977..7e978fe 100644 --- a/tests/basic/test_location_buttons.py +++ b/tests/basic/test_location_buttons.py @@ -217,8 +217,9 @@ def test_list_view_shows_tooltip_on_tap(self, mobile_page: Page, device_name: st """ mobile_page.goto(BASE_URL, wait_until="domcontentloaded") - # Tap the list view button + # Tap the list view button (wait for it to be ready first) list_view_button = mobile_page.locator("#listViewButton") + list_view_button.wait_for(state="visible") list_view_button.click() # Check tooltip appears with disabled message @@ -226,6 +227,9 @@ def test_list_view_shows_tooltip_on_tap(self, mobile_page: Page, device_name: st expect(tooltip).to_be_visible() expect(tooltip).to_contain_text("Location services are disabled") + # DEBUG: Keep tooltip visible for video capture - remove after debugging + mobile_page.wait_for_timeout(2000) + @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) def test_all_buttons_show_tooltip_on_tap(self, mobile_page: Page, device_name: str): """ @@ -241,15 +245,16 @@ def test_all_buttons_show_tooltip_on_tap(self, mobile_page: Page, device_name: s ] for selector, _name in buttons: - # Tap the button + # Tap the button (wait for it to be ready first) button = mobile_page.locator(selector) + button.wait_for(state="visible") button.click() # Check tooltip appears tooltip = mobile_page.locator('[role="tooltip"]') - expect(tooltip).to_be_visible(timeout=2000) + expect(tooltip).to_be_visible(timeout=200) expect(tooltip).to_contain_text("Location services are disabled") # Click elsewhere to dismiss tooltip and wait for it to disappear mobile_page.locator("body").click(position={"x": 10, "y": 10}) - expect(tooltip).not_to_be_visible(timeout=2000) + expect(tooltip).not_to_be_visible(timeout=200) diff --git a/tests/conftest.py b/tests/conftest.py index 5b609ad..a4ba284 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -141,6 +141,17 @@ def pytest_configure(config): print(f"\nUsing cached webpack script from {CACHE_FILE}") +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + """ + Hook to store test result on the item for use in fixtures. + Used by mobile_page fixture to determine if video should be retained. + """ + outcome = yield + rep = outcome.get_result() + setattr(item, f"rep_{rep.when}", rep) + + @pytest.fixture(scope="session") def webpack_script() -> str: """ @@ -262,9 +273,24 @@ def test_mobile(mobile_page, device_name): f"Unknown device_name={device_name}. Expected one of: {list(MOBILE_DEVICES)}" ) from e - # Create a new context with mobile user agent + # 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 + test_name = request.node.name.replace("[", "-").replace("]", "") + record_video_dir = str(Path(output_dir) / test_name) + # Set video size to match mobile viewport for clearer recordings + record_video_size = device_config["viewport"] + + # Create a new context with mobile user agent and optional video recording context = browser.new_context( - viewport=device_config["viewport"], user_agent=device_config["user_agent"] + viewport=device_config["viewport"], + user_agent=device_config["user_agent"], + record_video_dir=record_video_dir, + record_video_size=record_video_size, ) # Create a page from this context @@ -290,10 +316,25 @@ def block_hmr_route(route: Route) -> None: 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 test failed + test_failed = hasattr(request.node, "rep_call") and request.node.rep_call.failed + 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]]: From 5df55c41553b33372499af7ec969b07398f7ac82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 21 Jan 2026 00:12:27 +0100 Subject: [PATCH 07/12] fixed wrong server --- e2e_test_config.yml | 2 +- tests/basic/test_location_buttons.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/e2e_test_config.yml b/e2e_test_config.yml index 3a0223b..a012431 100644 --- a/e2e_test_config.yml +++ b/e2e_test_config.yml @@ -27,4 +27,4 @@ FEATURE_FLAGS: SHOW_SUGGEST_NEW_POINT_BUTTON: True -GOODMAP_FRONTEND_LIB_URL: "http://localhost:8080/index.min.js" +GOODMAP_FRONTEND_LIB_URL: "http://localhost:8080/index.js" diff --git a/tests/basic/test_location_buttons.py b/tests/basic/test_location_buttons.py index 7e978fe..b2c84df 100644 --- a/tests/basic/test_location_buttons.py +++ b/tests/basic/test_location_buttons.py @@ -227,9 +227,6 @@ def test_list_view_shows_tooltip_on_tap(self, mobile_page: Page, device_name: st expect(tooltip).to_be_visible() expect(tooltip).to_contain_text("Location services are disabled") - # DEBUG: Keep tooltip visible for video capture - remove after debugging - mobile_page.wait_for_timeout(2000) - @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) def test_all_buttons_show_tooltip_on_tap(self, mobile_page: Page, device_name: str): """ @@ -252,9 +249,9 @@ def test_all_buttons_show_tooltip_on_tap(self, mobile_page: Page, device_name: s # Check tooltip appears tooltip = mobile_page.locator('[role="tooltip"]') - expect(tooltip).to_be_visible(timeout=200) + expect(tooltip).to_be_visible(timeout=2000) expect(tooltip).to_contain_text("Location services are disabled") # Click elsewhere to dismiss tooltip and wait for it to disappear mobile_page.locator("body").click(position={"x": 10, "y": 10}) - expect(tooltip).not_to_be_visible(timeout=200) + expect(tooltip).not_to_be_visible(timeout=2000) From e4c528e8e9491352c78c4b2fdb273965e78614cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 21 Jan 2026 01:37:00 +0100 Subject: [PATCH 08/12] fix for tests --- tests/basic/test_left_panel.py | 68 ++++++++++++++++------------------ tests/conftest.py | 3 +- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/tests/basic/test_left_panel.py b/tests/basic/test_left_panel.py index 1034896..f1b719b 100644 --- a/tests/basic/test_left_panel.py +++ b/tests/basic/test_left_panel.py @@ -114,7 +114,7 @@ def test_panel_content_scrolls_on_desktop(self, page: Page): def test_all_filter_categories_accessible_on_desktop(self, page: Page): """ - All filter categories (types, gender, condition, type_of_place, transparency) + All filter categories (accessible_by, type_of_place) should be accessible, either visible or reachable by scrolling. """ page.set_viewport_size({"width": 1200, "height": 600}) @@ -126,16 +126,16 @@ def test_all_filter_categories_accessible_on_desktop(self, page: Page): panel_body = page.locator("#left-panel .offcanvas-body") panel_body.evaluate("el => el.scrollTop = el.scrollHeight") - # Check that transparency category (typically last) is visible after scrolling + # Check that type_of_place category is visible after scrolling # Look for the category header text - transparency_visible = page.evaluate( + category_visible = page.evaluate( """() => { const panel = document.querySelector('#left-panel .offcanvas-body'); const text = panel.textContent.toLowerCase(); - return text.includes('transparency') || text.includes('high') || text.includes('low'); + return text.includes('type_of_place') || text.includes('type of place'); }""" ) - assert transparency_visible, "Transparency category should be accessible after scrolling" + assert category_visible, "type_of_place category should be accessible after scrolling" class TestLeftPanelMobile: @@ -144,56 +144,55 @@ class TestLeftPanelMobile: @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) def test_panel_hidden_by_default_on_mobile(self, mobile_page: Page, device_name: str): """ - On mobile, the left panel should be hidden by default (offcanvas). + On mobile, the filter panel (dialog) should be hidden by default. """ mobile_page.goto(BASE_URL, wait_until="domcontentloaded") # Wait for page to load mobile_page.wait_for_load_state("networkidle") - # Panel should not be visually active (offcanvas is hidden) - panel = mobile_page.locator("#left-panel") - - # Check that panel doesn't have 'show' class (Bootstrap offcanvas) - has_show_class = panel.evaluate("el => el.classList.contains('show')") - assert not has_show_class, "Panel should not have 'show' class by default on mobile" + # Filter dialog should not be visible by default on mobile + filter_dialog = mobile_page.locator('[role="dialog"]') + expect(filter_dialog).not_to_be_visible() @pytest.mark.parametrize("device_name", ["iphone-6"]) def test_panel_opens_on_toggle_click(self, mobile_page: Page, device_name: str): """ - Clicking the toggle button should open the left panel as an offcanvas overlay. + Clicking the toggle button should open the filter panel dialog. """ mobile_page.goto(BASE_URL, wait_until="domcontentloaded") # Find and click the toggle button toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') - expect(toggle_button).to_be_visible() + toggle_button.wait_for(state="visible") toggle_button.click() - # Wait for panel to be visible - panel = mobile_page.locator("#left-panel") - expect(panel).to_be_visible(timeout=5000) + # Wait for filter dialog to be visible + filter_dialog = mobile_page.locator('[role="dialog"]') + expect(filter_dialog).to_be_visible(timeout=5000) - # Panel should have 'show' class when open - has_show_class = panel.evaluate("el => el.classList.contains('show')") - assert has_show_class, "Panel should have 'show' class when open" + # Filter form should be visible inside dialog + filter_form = mobile_page.locator('#filter-form') + expect(filter_form).to_be_visible() @pytest.mark.parametrize("device_name", ["iphone-6"]) def test_close_button_visible_on_mobile(self, mobile_page: Page, device_name: str): """ - On mobile, the panel should have a visible close button (X). + On mobile, the panel should have a visible close button. """ mobile_page.goto(BASE_URL, wait_until="domcontentloaded") # Open the panel toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') + toggle_button.wait_for(state="visible") toggle_button.click() - # Wait for panel to open - mobile_page.wait_for_selector("#left-panel.show", timeout=5000) + # Wait for dialog to open + filter_dialog = mobile_page.locator('[role="dialog"]') + expect(filter_dialog).to_be_visible(timeout=5000) # Close button should be visible - close_button = mobile_page.locator("#left-panel .btn-close") + close_button = mobile_page.locator('button[aria-label="Close left panel"]') expect(close_button).to_be_visible() @pytest.mark.parametrize("device_name", ["iphone-6"]) @@ -205,22 +204,19 @@ def test_close_button_closes_panel(self, mobile_page: Page, device_name: str): # Open the panel toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') + toggle_button.wait_for(state="visible") toggle_button.click() - # Wait for panel to open - mobile_page.wait_for_selector("#left-panel.show", timeout=5000) + # Wait for dialog to open + filter_dialog = mobile_page.locator('[role="dialog"]') + expect(filter_dialog).to_be_visible(timeout=5000) # Click close button - close_button = mobile_page.locator("#left-panel .btn-close") + close_button = mobile_page.locator('button[aria-label="Close left panel"]') close_button.click() - # Wait for panel to close - mobile_page.wait_for_selector("#left-panel:not(.show)", timeout=5000) - - # Verify panel is closed - panel = mobile_page.locator("#left-panel") - has_show_class = panel.evaluate("el => el.classList.contains('show')") - assert not has_show_class, "Panel should be closed after clicking close button" + # Verify dialog is closed + expect(filter_dialog).not_to_be_visible(timeout=5000) @pytest.mark.parametrize("device_name", ["iphone-6"]) def test_panel_width_on_mobile(self, mobile_page: Page, device_name: str): @@ -318,8 +314,8 @@ def test_filter_text_not_truncated_on_tablet(self, page: Page): # 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 "parcel_machine" in panel_text or "parcel machine" in panel_text.lower(), \ - "parcel_machine 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: diff --git a/tests/conftest.py b/tests/conftest.py index a4ba284..782e89a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -285,10 +285,11 @@ def test_mobile(mobile_page, device_name): # Set video size to match mobile viewport for clearer recordings record_video_size = device_config["viewport"] - # Create a new context with mobile user agent and optional video recording + # Create a new context with mobile user agent, touch support, and optional video recording 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, ) From 0086727cf0fcf5ead819ca8bc74dd1d740784463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 21 Jan 2026 02:54:45 +0100 Subject: [PATCH 09/12] removed some extra code --- e2e_test_config.yml | 2 +- tests/conftest.py | 27 +++------------------------ 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/e2e_test_config.yml b/e2e_test_config.yml index a012431..3a0223b 100644 --- a/e2e_test_config.yml +++ b/e2e_test_config.yml @@ -27,4 +27,4 @@ FEATURE_FLAGS: SHOW_SUGGEST_NEW_POINT_BUTTON: True -GOODMAP_FRONTEND_LIB_URL: "http://localhost:8080/index.js" +GOODMAP_FRONTEND_LIB_URL: "http://localhost:8080/index.min.js" diff --git a/tests/conftest.py b/tests/conftest.py index 782e89a..234b03c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,33 +29,12 @@ MARKER_LOAD_TIMEOUT = 5000 TABLE_LOAD_TIMEOUT = 5000 -# Script to remove webpack-dev-server overlay that can intercept clicks -# Uses CSS (when head exists) + MutationObserver (aggressive removal) +# Script to remove webpack-dev-server overlay (fallback, overlay should be disabled via --no-client-overlay) WEBPACK_OVERLAY_REMOVAL_SCRIPT = """ -// Inject CSS to hide overlay - append to head if exists, otherwise documentElement +// Hide webpack overlay if it appears (fallback safety) const style = document.createElement('style'); -style.textContent = `#webpack-dev-server-client-overlay { - display: none !important; - pointer-events: none !important; - visibility: hidden !important; -}`; +style.textContent = '#webpack-dev-server-client-overlay { display: none !important; }'; (document.head || document.documentElement).appendChild(style); - -// Aggressive MutationObserver - remove overlay immediately when it appears -const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - for (const node of mutation.addedNodes) { - if (node.id === 'webpack-dev-server-client-overlay') { - node.remove(); - return; - } - } - } - // Also check if it already exists - const overlay = document.getElementById('webpack-dev-server-client-overlay'); - if (overlay) overlay.remove(); -}); -observer.observe(document.documentElement, { childList: true, subtree: true }); """ # Mobile device configurations for Playwright From a7d1462bca2b887bd34f12d1801f155e6845bf48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 21 Jan 2026 04:22:04 +0100 Subject: [PATCH 10/12] fix comments --- .github/workflows/e2e-tests.yml | 18 +++++----- pyproject.toml | 1 - tests/basic/test_left_panel.py | 44 ++++++++++++++---------- tests/basic/test_location_buttons.py | 12 +++---- tests/conftest.py | 50 +++++++++------------------- 5 files changed, 58 insertions(+), 67 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d127bcd..3581f47 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -103,6 +103,15 @@ jobs: set -o pipefail make e2e-tests | tee /tmp/e2e-tests-output.txt + - name: Upload test videos on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-failure-videos + path: e2e-tests/test-results/**/*.webm + retention-days: 7 + if-no-files-found: ignore + - name: Stop backend for e2e tests if: always() shell: bash {0} @@ -200,15 +209,6 @@ jobs: retention-days: 1 if-no-files-found: ignore - - name: Upload test videos on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: test-failure-videos - path: e2e-tests/test-results/**/*.webm - retention-days: 7 - if-no-files-found: ignore - - name: Stop frontend server if: always() run: | diff --git a/pyproject.toml b/pyproject.toml index 857ae20..39eb03e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ python_functions = "test_*" base_url = "http://localhost:5000" # Browser/headed/slowmo should be passed via CLI: --browser=chromium --headed --slowmo=100 # Video recording: retain-on-failure keeps videos only for failed tests -# Videos are saved to test-results/ directory addopts = "--video=retain-on-failure --output=test-results" [tool.ruff] diff --git a/tests/basic/test_left_panel.py b/tests/basic/test_left_panel.py index f1b719b..430a3d3 100644 --- a/tests/basic/test_left_panel.py +++ b/tests/basic/test_left_panel.py @@ -17,7 +17,7 @@ import pytest from playwright.sync_api import Page, expect -from tests.conftest import ALL_MOBILE_DEVICES, BASE_URL, MOBILE_DEVICES +from tests.conftest import ALL_MOBILE_DEVICES, BASE_URL class TestLeftPanelDesktop: @@ -71,11 +71,18 @@ def test_no_page_scrollbar_on_desktop(self, page: Page): body_overflow = page.evaluate("() => getComputedStyle(document.body).overflow") assert body_overflow == "hidden", f"Expected body overflow: hidden, got: {body_overflow}" - # Check that body doesn't have vertical scroll + # Check that page is not actually scrollable (attempt scroll test) has_scroll = page.evaluate( - "() => document.body.scrollHeight > document.body.clientHeight" + """() => { + const el = document.scrollingElement || document.documentElement; + const before = el.scrollTop; + el.scrollTo(0, 100); + const after = el.scrollTop; + el.scrollTo(0, 0); + return after > before; + }""" ) - assert not has_scroll, "Page should not have vertical scroll" + assert not has_scroll, "Page should not be scrollable" def test_panel_content_scrolls_on_desktop(self, page: Page): """ @@ -141,8 +148,8 @@ def test_all_filter_categories_accessible_on_desktop(self, page: Page): class TestLeftPanelMobile: """Test suite for left panel on mobile viewport (<992px)""" - @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) - def test_panel_hidden_by_default_on_mobile(self, mobile_page: Page, device_name: str): + @pytest.mark.parametrize("mobile_page", ALL_MOBILE_DEVICES, indirect=True) + def test_panel_hidden_by_default_on_mobile(self, mobile_page: Page): """ On mobile, the filter panel (dialog) should be hidden by default. """ @@ -155,8 +162,8 @@ def test_panel_hidden_by_default_on_mobile(self, mobile_page: Page, device_name: filter_dialog = mobile_page.locator('[role="dialog"]') expect(filter_dialog).not_to_be_visible() - @pytest.mark.parametrize("device_name", ["iphone-6"]) - def test_panel_opens_on_toggle_click(self, mobile_page: Page, device_name: str): + @pytest.mark.parametrize("mobile_page", ["iphone-6"], indirect=True) + def test_panel_opens_on_toggle_click(self, mobile_page: Page): """ Clicking the toggle button should open the filter panel dialog. """ @@ -175,8 +182,8 @@ def test_panel_opens_on_toggle_click(self, mobile_page: Page, device_name: str): filter_form = mobile_page.locator('#filter-form') expect(filter_form).to_be_visible() - @pytest.mark.parametrize("device_name", ["iphone-6"]) - def test_close_button_visible_on_mobile(self, mobile_page: Page, device_name: str): + @pytest.mark.parametrize("mobile_page", ["iphone-6"], indirect=True) + def test_close_button_visible_on_mobile(self, mobile_page: Page): """ On mobile, the panel should have a visible close button. """ @@ -195,8 +202,8 @@ def test_close_button_visible_on_mobile(self, mobile_page: Page, device_name: st close_button = mobile_page.locator('button[aria-label="Close left panel"]') expect(close_button).to_be_visible() - @pytest.mark.parametrize("device_name", ["iphone-6"]) - def test_close_button_closes_panel(self, mobile_page: Page, device_name: str): + @pytest.mark.parametrize("mobile_page", ["iphone-6"], indirect=True) + def test_close_button_closes_panel(self, mobile_page: Page): """ Clicking the close button should close the panel. """ @@ -218,8 +225,8 @@ def test_close_button_closes_panel(self, mobile_page: Page, device_name: str): # Verify dialog is closed expect(filter_dialog).not_to_be_visible(timeout=5000) - @pytest.mark.parametrize("device_name", ["iphone-6"]) - def test_panel_width_on_mobile(self, mobile_page: Page, device_name: str): + @pytest.mark.parametrize("mobile_page", ["iphone-6"], indirect=True) + def test_panel_width_on_mobile(self, mobile_page: Page): """ On mobile, the panel should be 80vw wide (80% of viewport width). """ @@ -248,8 +255,8 @@ def test_panel_width_on_mobile(self, mobile_page: Page, device_name: str): expected_width * 0.9 <= actual_width <= expected_width * 1.1 ), f"Expected panel width ~{expected_width}px (80vw), got: {actual_width}px" - @pytest.mark.parametrize("device_name", ["iphone-6"]) - def test_no_page_scrollbar_on_mobile(self, mobile_page: Page, device_name: str): + @pytest.mark.parametrize("mobile_page", ["iphone-6"], indirect=True) + def test_no_page_scrollbar_on_mobile(self, mobile_page: Page): """ On mobile, there should be no page-level scrollbar. """ @@ -377,7 +384,10 @@ def test_offcanvas_body_has_overflow_auto(self, page: Page): page.wait_for_selector("#filter-form", timeout=10000) overflow_y = page.evaluate( - "() => getComputedStyle(document.querySelector('#left-panel .offcanvas-body')).overflowY" + """() => { + const el = document.querySelector('#left-panel .offcanvas-body'); + return getComputedStyle(el).overflowY; + }""" ) assert overflow_y == "auto", f"Expected overflow-y: auto, got: {overflow_y}" diff --git a/tests/basic/test_location_buttons.py b/tests/basic/test_location_buttons.py index b2c84df..1d32631 100644 --- a/tests/basic/test_location_buttons.py +++ b/tests/basic/test_location_buttons.py @@ -209,15 +209,15 @@ def test_all_buttons_grayed_out_when_location_not_granted(self, page: Page): class TestLocationButtonsMobile: """Test suite for location buttons on mobile devices""" - @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) - def test_list_view_shows_tooltip_on_tap(self, mobile_page: Page, device_name: str): + @pytest.mark.parametrize("mobile_page", ALL_MOBILE_DEVICES, indirect=True) + def test_list_view_shows_tooltip_on_tap(self, mobile_page: Page): """ Verify list view button shows tooltip immediately on tap when geolocation is not granted (mobile). """ mobile_page.goto(BASE_URL, wait_until="domcontentloaded") - # Tap the list view button (wait for it to be ready first) + # Tap the list view button list_view_button = mobile_page.locator("#listViewButton") list_view_button.wait_for(state="visible") list_view_button.click() @@ -227,8 +227,8 @@ def test_list_view_shows_tooltip_on_tap(self, mobile_page: Page, device_name: st expect(tooltip).to_be_visible() expect(tooltip).to_contain_text("Location services are disabled") - @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) - def test_all_buttons_show_tooltip_on_tap(self, mobile_page: Page, device_name: str): + @pytest.mark.parametrize("mobile_page", ALL_MOBILE_DEVICES, indirect=True) + def test_all_buttons_show_tooltip_on_tap(self, mobile_page: Page): """ Verify all three location buttons show tooltips consistently on tap (mobile). Tests that enterTouchDelay=0 is working for all buttons. @@ -242,7 +242,7 @@ def test_all_buttons_show_tooltip_on_tap(self, mobile_page: Page, device_name: s ] for selector, _name in buttons: - # Tap the button (wait for it to be ready first) + # Tap the button button = mobile_page.locator(selector) button.wait_for(state="visible") button.click() diff --git a/tests/conftest.py b/tests/conftest.py index 234b03c..6b58249 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,14 +29,6 @@ MARKER_LOAD_TIMEOUT = 5000 TABLE_LOAD_TIMEOUT = 5000 -# Script to remove webpack-dev-server overlay (fallback, overlay should be disabled via --no-client-overlay) -WEBPACK_OVERLAY_REMOVAL_SCRIPT = """ -// Hide webpack overlay if it appears (fallback safety) -const style = document.createElement('style'); -style.textContent = '#webpack-dev-server-client-overlay { display: none !important; }'; -(document.head || document.documentElement).appendChild(style); -""" - # Mobile device configurations for Playwright MOBILE_DEVICES = { "iphone-x": { @@ -121,7 +113,7 @@ def pytest_configure(config): @pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_makereport(item, call): +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. @@ -174,9 +166,6 @@ def block_hmr_route(route: Route) -> None: page.route("**/ws", block_hmr_route) page.route("**/*.hot-update.*", block_hmr_route) - # Remove webpack-dev-server overlay that can intercept clicks - page.add_init_script(WEBPACK_OVERLAY_REMOVAL_SCRIPT) - return page @@ -225,25 +214,15 @@ def mobile_page(browser, webpack_script: str, request) -> Generator[Page, None, This fixture creates a new browser context with the correct user agent, ensuring that react-device-detect properly identifies the device as mobile. - The device configuration is passed via parametrize with the 'device_name' parameter. + The device is passed via indirect parametrization. Example: - @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) - def test_mobile(mobile_page, device_name): + @pytest.mark.parametrize("mobile_page", ALL_MOBILE_DEVICES, indirect=True) + def test_mobile(mobile_page): mobile_page.goto(BASE_URL) # ... test mobile-specific behavior """ - # Get device_name from parametrize (safely handle missing callspec) - callspec = getattr(request.node, "callspec", None) - if callspec is None: - raise ValueError( - "mobile_page fixture requires @pytest.mark.parametrize with 'device_name' parameter" - ) - device_name = callspec.params.get("device_name") - if not device_name: - raise ValueError( - "mobile_page fixture requires 'device_name' parameter from @pytest.mark.parametrize" - ) + device_name = request.param try: device_config = MOBILE_DEVICES[device_name] @@ -258,9 +237,12 @@ def test_mobile(mobile_page, device_name): 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 - test_name = request.node.name.replace("[", "-").replace("]", "") - record_video_dir = str(Path(output_dir) / test_name) + # 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"] @@ -291,9 +273,6 @@ def block_hmr_route(route: Route) -> None: page.route("**/ws", block_hmr_route) page.route("**/*.hot-update.*", block_hmr_route) - # Remove webpack-dev-server overlay that can intercept clicks - page.add_init_script(WEBPACK_OVERLAY_REMOVAL_SCRIPT) - yield page # Get video path before closing (video is saved on close) @@ -306,8 +285,11 @@ def block_hmr_route(route: Route) -> None: # Handle retain-on-failure: delete video if test passed if video_path and video_option == "retain-on-failure": - # Check if test failed - test_failed = hasattr(request.node, "rep_call") and request.node.rep_call.failed + # 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 From 4c88b6ac0390134632e73ff594e54f51d534e8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 21 Jan 2026 11:01:17 +0100 Subject: [PATCH 11/12] fix failing tests --- tests/basic/test_left_panel.py | 5 +++-- tests/conftest.py | 24 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/basic/test_left_panel.py b/tests/basic/test_left_panel.py index 430a3d3..5a54d43 100644 --- a/tests/basic/test_left_panel.py +++ b/tests/basic/test_left_panel.py @@ -155,8 +155,9 @@ def test_panel_hidden_by_default_on_mobile(self, mobile_page: Page): """ mobile_page.goto(BASE_URL, wait_until="domcontentloaded") - # Wait for page to load - mobile_page.wait_for_load_state("networkidle") + # Wait for toggle button to be visible (indicates page is ready) + toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') + toggle_button.wait_for(state="visible") # Filter dialog should not be visible by default on mobile filter_dialog = mobile_page.locator('[role="dialog"]') diff --git a/tests/conftest.py b/tests/conftest.py index 6b58249..127b919 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -214,15 +214,27 @@ def mobile_page(browser, webpack_script: str, request) -> Generator[Page, None, This fixture creates a new browser context with the correct user agent, ensuring that react-device-detect properly identifies the device as mobile. - The device is passed via indirect parametrization. - - Example: + Supports two parametrization styles: + 1. Indirect (preferred): @pytest.mark.parametrize("mobile_page", ALL_MOBILE_DEVICES, indirect=True) def test_mobile(mobile_page): - mobile_page.goto(BASE_URL) - # ... test mobile-specific behavior + ... + + 2. Legacy (device_name parameter): + @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) + def test_mobile(mobile_page, device_name): + ... """ - device_name = request.param + # Support both indirect parametrization and legacy device_name parameter + if hasattr(request, "param"): + device_name = request.param + else: + callspec = getattr(request.node, "callspec", None) + if callspec is None: + raise ValueError("mobile_page fixture requires parametrization") + device_name = callspec.params.get("device_name") + if not device_name: + raise ValueError("mobile_page fixture requires 'device_name' parameter") try: device_config = MOBILE_DEVICES[device_name] From 83d49e72769b00f073636f34b80820feed006eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 21 Jan 2026 13:05:57 +0100 Subject: [PATCH 12/12] fix review comments --- tests/basic/test_left_panel.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/basic/test_left_panel.py b/tests/basic/test_left_panel.py index 5a54d43..1db2178 100644 --- a/tests/basic/test_left_panel.py +++ b/tests/basic/test_left_panel.py @@ -235,6 +235,7 @@ def test_panel_width_on_mobile(self, mobile_page: Page): # Open the panel toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') + toggle_button.wait_for(state="visible") toggle_button.click() # Wait for panel to open @@ -265,6 +266,7 @@ def test_no_page_scrollbar_on_mobile(self, mobile_page: Page): # Open the panel toggle_button = mobile_page.locator('button[aria-label="Toggle left panel"]') + toggle_button.wait_for(state="visible") toggle_button.click() # Wait for panel to open and content to load @@ -307,6 +309,7 @@ def test_filter_text_not_truncated_on_tablet(self, page: Page): # Open the panel toggle_button = page.locator('button[aria-label="Toggle left panel"]') + toggle_button.wait_for(state="visible") toggle_button.click() # Wait for panel to open and content to load