diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 26ab386..3581f47 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 @@ -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} 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/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/pyproject.toml b/pyproject.toml index db248a8..39eb03e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ 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 +addopts = "--video=retain-on-failure --output=test-results" [tool.ruff] line-length = 100 diff --git a/tests/basic/test_left_panel.py b/tests/basic/test_left_panel.py new file mode 100644 index 0000000..1db2178 --- /dev/null +++ b/tests/basic/test_left_panel.py @@ -0,0 +1,397 @@ +""" +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 + + +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 page is not actually scrollable (attempt scroll test) + has_scroll = page.evaluate( + """() => { + 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 be scrollable" + + 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 (accessible_by, type_of_place) + 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 type_of_place category is visible after scrolling + # Look for the category header text + category_visible = page.evaluate( + """() => { + const panel = document.querySelector('#left-panel .offcanvas-body'); + const text = panel.textContent.toLowerCase(); + return text.includes('type_of_place') || text.includes('type of place'); + }""" + ) + assert category_visible, "type_of_place category should be accessible after scrolling" + + +class TestLeftPanelMobile: + """Test suite for left panel on mobile viewport (<992px)""" + + @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. + """ + mobile_page.goto(BASE_URL, wait_until="domcontentloaded") + + # 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"]') + expect(filter_dialog).not_to_be_visible() + + @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. + """ + 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"]') + toggle_button.wait_for(state="visible") + toggle_button.click() + + # Wait for filter dialog to be visible + filter_dialog = mobile_page.locator('[role="dialog"]') + expect(filter_dialog).to_be_visible(timeout=5000) + + # Filter form should be visible inside dialog + filter_form = mobile_page.locator('#filter-form') + expect(filter_form).to_be_visible() + + @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. + """ + 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 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('button[aria-label="Close left panel"]') + expect(close_button).to_be_visible() + + @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. + """ + 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 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('button[aria-label="Close left panel"]') + close_button.click() + + # Verify dialog is closed + expect(filter_dialog).not_to_be_visible(timeout=5000) + + @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). + """ + 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) + + # 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("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. + """ + 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 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.wait_for(state="visible") + 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 "accessible_by" in panel_text or "accessible by" in panel_text.lower(), \ + "accessible_by 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( + """() => { + 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 2d7e264..1d32631 100644 --- a/tests/basic/test_location_buttons.py +++ b/tests/basic/test_location_buttons.py @@ -209,8 +209,8 @@ 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). @@ -218,17 +218,17 @@ 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.wait_for(state="visible") + list_view_button.click() # Check tooltip appears with disabled message tooltip = mobile_page.locator('[role="tooltip"]') 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. @@ -243,9 +243,9 @@ 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.wait_for(state="visible") + button.click() # Check tooltip appears tooltip = mobile_page.locator('[role="tooltip"]') @@ -253,5 +253,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..127b919 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" @@ -29,35 +29,6 @@ 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) -WEBPACK_OVERLAY_REMOVAL_SCRIPT = """ -// Inject CSS to hide overlay - append to head if exists, otherwise documentElement -const style = document.createElement('style'); -style.textContent = `#webpack-dev-server-client-overlay { - display: none !important; - pointer-events: none !important; - visibility: hidden !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 MOBILE_DEVICES = { "iphone-x": { @@ -141,6 +112,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): # 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. + """ + outcome = yield + rep = outcome.get_result() + setattr(item, f"rep_{rep.when}", rep) + + @pytest.fixture(scope="session") def webpack_script() -> str: """ @@ -184,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 @@ -235,25 +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 configuration is passed via parametrize with the 'device_name' parameter. + Supports two parametrization styles: + 1. Indirect (preferred): + @pytest.mark.parametrize("mobile_page", ALL_MOBILE_DEVICES, indirect=True) + def test_mobile(mobile_page): + ... - Example: + 2. Legacy (device_name parameter): @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES) def test_mobile(mobile_page, device_name): - 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" - ) + # 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] @@ -262,11 +243,28 @@ 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 + # 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 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 @@ -287,15 +285,30 @@ 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) + 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]]: