Skip to content

Commit d71cb5e

Browse files
fix: Fix playwright gateway action tests (#3995)
* fix: Fix playwright gateway action tests Signed-off-by: Gabriel Costa <gabrielcg@proton.me> * fix: add missing _open_action_dropdown call in delete_gateway_by_url The delete_gateway_by_url method was missing the _open_action_dropdown call added by the previous commit. Since PR #3802 moved all action buttons (including Delete) inside an Alpine.js dropdown menu, the delete button is not directly clickable without first opening the menu. Closes #3982 Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix: open action dropdown in remaining test files that bypass page object Three test files directly click gateway action buttons without going through the GatewaysPage page object, so they were also broken by PR #3802's move to the Alpine.js overflow menu: - test_rbac_permissions.py: test_admin_delete_gateway bypassed page object to inspect HTTP response status directly - test_admin_url_context.py: _get_delete_gateway_btn helper used by multiple URL-context tests and iframe delete tests - test_iframe_embedding_security.py: delete and toggle tests operated directly on iframe FrameLocator Each now opens the actions dropdown before clicking the menu item. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: Gabriel Costa <gabrielcg@proton.me> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
1 parent 5061516 commit d71cb5e

4 files changed

Lines changed: 178 additions & 256 deletions

File tree

tests/playwright/pages/gateways_page.py

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
import logging
1212

1313
# Third-Party
14-
from playwright.sync_api import Error as PlaywrightError, expect, Locator
14+
from playwright.sync_api import Error as PlaywrightError
15+
from playwright.sync_api import expect, Locator
1516
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
1617

1718
# Local
@@ -465,9 +466,7 @@ def search_gateways(self, query: str) -> None:
465466
if self.get_gateway_count() == 0:
466467
# Clear URL search params before reloading so the x-init HTMX load
467468
# does not re-apply the search term and leave the table empty again.
468-
self.page.evaluate(
469-
"window.history.replaceState({}, '', window.location.pathname + window.location.hash)"
470-
)
469+
self.page.evaluate("window.history.replaceState({}, '', window.location.pathname + window.location.hash)")
471470
self.page.reload(wait_until="domcontentloaded")
472471
self.navigate_to_gateways_tab()
473472
self.wait_for_gateways_table_loaded()
@@ -593,14 +592,31 @@ def get_gateway_count(self) -> int:
593592

594593
# ==================== Gateway Row Actions ====================
595594

595+
def _open_action_dropdown(self, gateway_row: Locator) -> None:
596+
"""Open the three-dot actions dropdown for a gateway row.
597+
598+
PR #3802 moved all action buttons inside an Alpine.js-controlled
599+
dropdown (menuOpen: false by default). This helper clicks the trigger
600+
and waits for the menu to become visible before callers attempt to
601+
click individual menu items.
602+
603+
Args:
604+
gateway_row: Playwright Locator for the <tr> row element
605+
"""
606+
gateway_row.scroll_into_view_if_needed()
607+
trigger = gateway_row.locator("button[aria-expanded]")
608+
trigger.click()
609+
gateway_row.locator('[role="menu"]').wait_for(state="visible", timeout=5000)
610+
596611
def click_test_button(self, gateway_index: int = 0) -> None:
597612
"""Click the Test button for a gateway.
598613
599614
Args:
600615
gateway_index: Index of the gateway row (default: 0 for first gateway)
601616
"""
602617
gateway_row = self.gateway_rows.nth(gateway_index)
603-
test_btn = gateway_row.locator('button:has-text("Test")')
618+
self._open_action_dropdown(gateway_row)
619+
test_btn = gateway_row.locator('button[role="menuitem"]:has-text("Test")')
604620
self.click_locator(test_btn)
605621

606622
def click_view_button(self, gateway_index: int = 0) -> None:
@@ -610,7 +626,8 @@ def click_view_button(self, gateway_index: int = 0) -> None:
610626
gateway_index: Index of the gateway row (default: 0 for first gateway)
611627
"""
612628
gateway_row = self.gateway_rows.nth(gateway_index)
613-
view_btn = gateway_row.locator('button:has-text("View")')
629+
self._open_action_dropdown(gateway_row)
630+
view_btn = gateway_row.locator('button[role="menuitem"]:has-text("View")')
614631
self.click_locator(view_btn)
615632

616633
def click_edit_button(self, gateway_index: int = 0) -> None:
@@ -620,7 +637,8 @@ def click_edit_button(self, gateway_index: int = 0) -> None:
620637
gateway_index: Index of the gateway row (default: 0 for first gateway)
621638
"""
622639
gateway_row = self.gateway_rows.nth(gateway_index)
623-
edit_btn = gateway_row.locator('button:has-text("Edit")')
640+
self._open_action_dropdown(gateway_row)
641+
edit_btn = gateway_row.locator('button[role="menuitem"]:has-text("Edit")')
624642
self.click_locator(edit_btn)
625643

626644
def click_deactivate_button(self, gateway_index: int = 0) -> None:
@@ -630,7 +648,8 @@ def click_deactivate_button(self, gateway_index: int = 0) -> None:
630648
gateway_index: Index of the gateway row (default: 0 for first gateway)
631649
"""
632650
gateway_row = self.gateway_rows.nth(gateway_index)
633-
deactivate_btn = gateway_row.locator('button:has-text("Deactivate")')
651+
self._open_action_dropdown(gateway_row)
652+
deactivate_btn = gateway_row.locator('button[role="menuitem"]:has-text("Deactivate")')
634653
self.click_locator(deactivate_btn)
635654

636655
def click_activate_button(self, gateway_index: int = 0) -> None:
@@ -640,7 +659,8 @@ def click_activate_button(self, gateway_index: int = 0) -> None:
640659
gateway_index: Index of the gateway row (default: 0 for first gateway)
641660
"""
642661
gateway_row = self.gateway_rows.nth(gateway_index)
643-
activate_btn = gateway_row.locator('button:text-is("Activate")')
662+
self._open_action_dropdown(gateway_row)
663+
activate_btn = gateway_row.locator('button[role="menuitem"]:text-is("Activate")')
644664
self.click_locator(activate_btn)
645665

646666
def _click_delete_and_wait(self, delete_btn, confirm: bool = True) -> None:
@@ -690,11 +710,7 @@ def delete_gateway(self, gateway_index: int = 0, confirm: bool = True) -> None:
690710
confirm: Whether to confirm the deletion dialog (default: True)
691711
"""
692712
gateway_row = self.gateway_rows.nth(gateway_index)
693-
694-
# Scroll the row into view first
695-
gateway_row.scroll_into_view_if_needed()
696-
697-
# Find the delete button within the row's action column
713+
self._open_action_dropdown(gateway_row)
698714
delete_btn = gateway_row.locator('form[action*="/delete"] button[type="submit"]:has-text("Delete")')
699715
self._click_delete_and_wait(delete_btn, confirm)
700716

@@ -721,7 +737,7 @@ def delete_gateway_by_name(self, gateway_name: str, confirm: bool = True) -> boo
721737
try:
722738
gateway_row = self.get_gateway_row_by_name(gateway_name)
723739
gateway_row.first.wait_for(state="attached", timeout=5000)
724-
gateway_row.first.scroll_into_view_if_needed()
740+
self._open_action_dropdown(gateway_row.first)
725741
delete_btn = gateway_row.first.locator('form[action*="/delete"] button[type="submit"]:has-text("Delete")')
726742
self._click_delete_and_wait(delete_btn, confirm)
727743
return True
@@ -773,6 +789,7 @@ def delete_gateway_by_url(self, gateway_url: str, confirm: bool = True) -> bool:
773789

774790
# Get the delete button and use shared delete+navigation helper
775791
try:
792+
self._open_action_dropdown(gateway_row.first)
776793
delete_btn = gateway_row.first.locator('form[action*="/delete"] button[type="submit"]:has-text("Delete")')
777794
self._click_delete_and_wait(delete_btn, confirm)
778795

@@ -882,8 +899,7 @@ def set_transport_http_bypass(self) -> None:
882899
This allows tests that care about auth configuration (not reachability) to
883900
complete without a live gateway.
884901
"""
885-
self.page.evaluate(
886-
"""() => {
902+
self.page.evaluate("""() => {
887903
const form = document.getElementById('add-gateway-form');
888904
if (!form) return;
889905
const select = form.querySelector('select[name="transport"]');
@@ -895,8 +911,7 @@ def set_transport_http_bypass(self) -> None:
895911
select.appendChild(opt);
896912
}
897913
select.value = 'HTTP';
898-
}"""
899-
)
914+
}""")
900915

901916
def toggle_one_time_auth(self, enable: bool = True) -> None:
902917
"""Toggle one-time authentication checkbox.

tests/playwright/security/test_iframe_embedding_security.py

Lines changed: 26 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,7 @@ def _find_cookie(page: Page, name: str) -> Optional[Dict[str, Any]]:
5757

5858
# Iframe tests require X_FRAME_OPTIONS=ALLOW-ALL to function properly
5959
# Skip marker for when this requirement is not met
60-
_requires_allow_all = pytest.mark.skipif(
61-
settings.x_frame_options != "ALLOW-ALL",
62-
reason="Iframe embedding tests require X_FRAME_OPTIONS=ALLOW-ALL environment variable"
63-
)
60+
_requires_allow_all = pytest.mark.skipif(settings.x_frame_options != "ALLOW-ALL", reason="Iframe embedding tests require X_FRAME_OPTIONS=ALLOW-ALL environment variable")
6461

6562

6663
@pytest.fixture
@@ -203,13 +200,11 @@ def test_x_frame_options_deny_blocks_iframe(self, page: Page, base_url: str):
203200

204201
_ensure_admin_logged_in(page, base_url)
205202
admin_url = f"{base_url}/admin/"
206-
page.set_content(
207-
f"""<!DOCTYPE html>
203+
page.set_content(f"""<!DOCTYPE html>
208204
<html><head><title>deny test</title></head>
209205
<body>
210206
<iframe id="admin-frame" src="{admin_url}" style="width:100%;height:100vh;border:none"></iframe>
211-
</body></html>"""
212-
)
207+
</body></html>""")
213208

214209
frame = page.frame_locator("#admin-frame")
215210
try:
@@ -376,15 +371,13 @@ def _strip_headers(route: Route) -> None:
376371
page.route(admin_pattern, _strip_headers)
377372

378373
admin_url = f"{base_url}/admin/?ui_hide=metrics"
379-
page.set_content(
380-
f"""<!DOCTYPE html>
374+
page.set_content(f"""<!DOCTYPE html>
381375
<html><head><title>ui_hide iframe test</title></head>
382376
<body>
383377
<iframe id="admin-frame" src="{admin_url}" style="width:100%;height:100vh;border:none"
384378
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals">
385379
</iframe>
386-
</body></html>"""
387-
)
380+
</body></html>""")
388381

389382
frame = page.frame_locator("#admin-frame")
390383
try:
@@ -440,16 +433,14 @@ class TestCORSInIframeContext:
440433
def test_same_origin_fetch_from_iframe(self, page: Page, iframe_host):
441434
"""fetch('/health') from inside the iframe succeeds (same-origin)."""
442435
frame = iframe_host
443-
result = frame.locator("body").evaluate(
444-
"""async () => {
436+
result = frame.locator("body").evaluate("""async () => {
445437
try {
446438
const resp = await fetch('/health');
447439
return { status: resp.status, ok: resp.ok };
448440
} catch (e) {
449441
return { error: e.message };
450442
}
451-
}"""
452-
)
443+
}""")
453444
assert "error" not in result, f"Same-origin fetch from iframe failed: {result.get('error')}"
454445
assert result["status"] == 200, f"Expected 200 from /health, got {result['status']}"
455446

@@ -595,10 +586,7 @@ def _reload_iframe(self, page: Page, iframe_host: FrameLocator) -> None:
595586
iframe_host.locator('[data-testid="servers-tab"]').wait_for(state="visible", timeout=30000)
596587

597588
# Wait for JS initialization (Admin object + HTMX) - also critical
598-
iframe_frame.wait_for_function(
599-
"typeof window.Admin !== 'undefined' && typeof window.Admin.showTab === 'function' && typeof window.htmx !== 'undefined'",
600-
timeout=15000
601-
)
589+
iframe_frame.wait_for_function("typeof window.Admin !== 'undefined' && typeof window.Admin.showTab === 'function' && typeof window.htmx !== 'undefined'", timeout=15000)
602590

603591
# Give HTMX a moment to settle after page load
604592
page.wait_for_timeout(500)
@@ -619,8 +607,7 @@ def _search_gateway_in_iframe(self, page: Page, frame: FrameLocator, gw_name: st
619607
search_input.fill(gw_name)
620608
# Wait for the HTMX indicator to disappear (search response settled)
621609
page.wait_for_function(
622-
"() => !document.querySelector('#admin-frame')?.contentDocument"
623-
"?.querySelector('#gateways-loading.htmx-request')",
610+
"() => !document.querySelector('#admin-frame')?.contentDocument" "?.querySelector('#gateways-loading.htmx-request')",
624611
timeout=10000,
625612
)
626613
except (PlaywrightTimeoutError, Exception):
@@ -649,9 +636,7 @@ def _navigate_to_gateways_tab(self, page: Page, frame: FrameLocator) -> None:
649636
panel.wait_for(state="visible", timeout=5000)
650637
except PlaywrightTimeoutError:
651638
try:
652-
iframe_frame.evaluate(
653-
"() => { if (typeof window.Admin !== 'undefined' && typeof window.Admin.showTab === 'function') window.Admin.showTab('gateways'); }"
654-
)
639+
iframe_frame.evaluate("() => { if (typeof window.Admin !== 'undefined' && typeof window.Admin.showTab === 'function') window.Admin.showTab('gateways'); }")
655640
except Exception:
656641
pass
657642
panel.wait_for(state="visible", timeout=15000)
@@ -665,7 +650,7 @@ def _navigate_to_gateways_tab(self, page: Page, frame: FrameLocator) -> None:
665650
// Either has data rows or shows empty state
666651
return tbody.children.length > 0 || tbody.textContent.includes('No');
667652
}""",
668-
timeout=15000
653+
timeout=15000,
669654
)
670655
except PlaywrightTimeoutError:
671656
pass # Table may still be loading; proceed with what is available
@@ -716,8 +701,7 @@ def test_add_gateway_via_iframe_form(self, page: Page, iframe_host: FrameLocator
716701
# Override transport to HTTP so _initialize_gateway skips the external
717702
# connection check (only SSE/StreamableHTTP try to connect).
718703
iframe_frame = page.frames[-1]
719-
iframe_frame.evaluate(
720-
"""() => {
704+
iframe_frame.evaluate("""() => {
721705
const form = document.getElementById('add-gateway-form');
722706
if (!form) return;
723707
const select = form.querySelector('select[name="transport"]');
@@ -729,8 +713,7 @@ def test_add_gateway_via_iframe_form(self, page: Page, iframe_host: FrameLocator
729713
select.appendChild(opt);
730714
}
731715
select.value = 'HTTP';
732-
}"""
733-
)
716+
}""")
734717

735718
# Submit and wait for the POST response from the admin form handler.
736719
# The admin form uses JS fetch() so the page does NOT navigate.
@@ -846,7 +829,7 @@ def test_edit_gateway_via_iframe_modal(self, page: Page, iframe_host: FrameLocat
846829

847830
# Click the edit button for this specific gateway
848831
# tojson_attr encodes the ID with double quotes → onclick='Admin.editGateway("id")'
849-
edit_btn = frame.locator(f'button[onclick*=\'editGateway("{gw_id}")\']').first
832+
edit_btn = frame.locator(f"button[onclick*='editGateway(\"{gw_id}\")']").first
850833
try:
851834
edit_btn.wait_for(state="visible", timeout=15000)
852835
except PlaywrightTimeoutError:
@@ -896,9 +879,13 @@ def test_delete_gateway_via_iframe(self, page: Page, iframe_host: FrameLocator,
896879
# Auto-accept confirmation dialogs on the HOST page
897880
page.on("dialog", lambda d: d.accept())
898881

899-
# Click delete button (use .first for responsive layouts)
882+
# Open the actions dropdown (PR #3802 moved buttons into Alpine.js menu)
883+
gw_row = frame.locator(f'tr[id="gateway-row-{gw_id}"]').first
884+
gw_row.wait_for(state="attached", timeout=10000)
885+
gw_row.scroll_into_view_if_needed()
886+
gw_row.locator("button[aria-expanded]").click()
887+
gw_row.locator('[role="menu"]').wait_for(state="visible", timeout=5000)
900888
delete_btn = frame.locator(f'form[action*="/gateways/{gw_id}/delete"] button[type="submit"]').first
901-
delete_btn.wait_for(state="visible", timeout=5000)
902889

903890
with page.expect_response(lambda r: f"/gateways/{gw_id}/delete" in r.url, timeout=15000):
904891
delete_btn.click()
@@ -926,9 +913,13 @@ def test_toggle_gateway_state_via_iframe(self, page: Page, iframe_host: FrameLoc
926913
# Search by name so the gateway is visible regardless of pagination
927914
self._search_gateway_in_iframe(page, frame, gw_name)
928915

929-
# Click the deactivate/toggle button for this gateway
916+
# Open the actions dropdown (PR #3802 moved buttons into Alpine.js menu)
917+
gw_row = frame.locator(f'tr[id="gateway-row-{gw_id}"]').first
918+
gw_row.wait_for(state="attached", timeout=10000)
919+
gw_row.scroll_into_view_if_needed()
920+
gw_row.locator("button[aria-expanded]").click()
921+
gw_row.locator('[role="menu"]').wait_for(state="visible", timeout=5000)
930922
toggle_btn = frame.locator(f'form[action*="/gateways/{gw_id}/state"] button[type="submit"]')
931-
toggle_btn.first.wait_for(state="visible", timeout=5000)
932923

933924
with page.expect_response(lambda r: f"/gateways/{gw_id}/state" in r.url, timeout=15000):
934925
toggle_btn.first.click()

0 commit comments

Comments
 (0)