mirror of
https://github.com/wizarrrr/wizarr.git
synced 2026-08-02 16:27:28 -04:00
Keep form-based invitation screens open on account creation failures so users can see errors without replaying the welcome animation. Centralize join form template context, preserve theming across invite routes, and add regression coverage for validation and E2E flows.
610 lines
24 KiB
Python
610 lines
24 KiB
Python
"""
|
|
End-to-end tests for invitation workflows using Playwright.
|
|
|
|
These tests simulate the complete user journey from receiving an invitation
|
|
link to successfully creating accounts on media servers.
|
|
"""
|
|
|
|
import contextlib
|
|
import multiprocessing
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from playwright.sync_api import Page, expect # type: ignore
|
|
|
|
# Fix for Python 3.14+ multiprocessing compatibility with pytest-flask live_server
|
|
# GitHub Actions uses spawn/forkserver by default which can't pickle local functions
|
|
# Force 'fork' method before any fixtures initialize
|
|
with contextlib.suppress(RuntimeError):
|
|
# RuntimeError raised if method already set, which is fine
|
|
multiprocessing.set_start_method("fork", force=True)
|
|
|
|
from app import create_app
|
|
from app.config import BaseConfig
|
|
from app.extensions import db
|
|
from app.models import AdminAccount, Invitation, MediaServer, Settings
|
|
from tests.mocks.media_server_mocks import (
|
|
create_mock_client,
|
|
setup_mock_servers,
|
|
)
|
|
|
|
|
|
class E2ETestConfig(BaseConfig):
|
|
TESTING = True
|
|
WTF_CSRF_ENABLED = False
|
|
# Use a temporary file database that both test process and live server can access
|
|
SQLALCHEMY_DATABASE_URI = f"sqlite:///{tempfile.gettempdir()}/wizarr_e2e_test.db"
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def app():
|
|
"""Create app with file-based database for E2E tests."""
|
|
# Clean up any existing test database
|
|
test_db_path = f"{tempfile.gettempdir()}/wizarr_e2e_test.db"
|
|
if os.path.exists(test_db_path):
|
|
os.remove(test_db_path)
|
|
|
|
app = create_app(E2ETestConfig) # type: ignore
|
|
with app.app_context():
|
|
db.create_all()
|
|
yield app
|
|
|
|
# Cleanup after tests
|
|
with app.app_context():
|
|
db.drop_all()
|
|
if os.path.exists(test_db_path):
|
|
os.remove(test_db_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def invitation_setup(app):
|
|
"""Setup test data for invitation E2E tests."""
|
|
with app.app_context():
|
|
setup_mock_servers()
|
|
|
|
# Create admin account if it doesn't exist (to bypass setup redirect)
|
|
admin = AdminAccount.query.filter_by(username="testadmin").first()
|
|
changes_made = False
|
|
if not admin:
|
|
admin = AdminAccount(username="testadmin")
|
|
admin.set_password("testpass")
|
|
db.session.add(admin)
|
|
changes_made = True
|
|
|
|
# Create admin_username setting to complete setup (if not exists)
|
|
admin_setting = Settings.query.filter_by(key="admin_username").first()
|
|
if not admin_setting:
|
|
admin_setting = Settings(key="admin_username", value="testadmin")
|
|
db.session.add(admin_setting)
|
|
changes_made = True
|
|
|
|
# Commit admin setup changes if any were made
|
|
if changes_made:
|
|
db.session.commit()
|
|
|
|
# Create media server
|
|
server = MediaServer(
|
|
name="Test Jellyfin Server",
|
|
server_type="jellyfin",
|
|
url="http://localhost:8096",
|
|
api_key="test-api-key",
|
|
)
|
|
db.session.add(server)
|
|
db.session.flush()
|
|
|
|
# Create valid invitation
|
|
invitation = Invitation(
|
|
code="E2ETEST123",
|
|
duration="30",
|
|
used=False,
|
|
unlimited=False,
|
|
)
|
|
invitation.servers = [server]
|
|
db.session.add(invitation)
|
|
db.session.commit()
|
|
|
|
yield {
|
|
"server": server,
|
|
"invitation": invitation,
|
|
"invitation_url": f"/j/{invitation.code}",
|
|
}
|
|
|
|
|
|
class TestInvitationUserJourney:
|
|
"""Test complete user journey through invitation process."""
|
|
|
|
@patch("app.services.media.service.get_client_for_media_server")
|
|
def test_successful_invitation_flow(
|
|
self, mock_get_client, page: Page, live_server, invitation_setup
|
|
):
|
|
"""Test successful invitation acceptance and account creation."""
|
|
# Setup mock client
|
|
mock_client = create_mock_client(
|
|
"jellyfin", server_id=invitation_setup["server"].id
|
|
)
|
|
mock_get_client.return_value = mock_client
|
|
|
|
# Navigate to invitation page
|
|
page.goto(f"{live_server.url()}{invitation_setup['invitation_url']}")
|
|
|
|
# Verify invitation page loads
|
|
expect(page.locator("h1").first).to_contain_text("been invited")
|
|
|
|
# Click "Accept Invitation" button to show the form
|
|
page.click("#accept-invite-btn")
|
|
|
|
# Wait for form fields to become visible (they animate in sequentially)
|
|
page.wait_for_selector(
|
|
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
|
|
timeout=10000,
|
|
)
|
|
|
|
# Check that form is present after clicking Accept Invitation
|
|
expect(page.locator("form")).to_be_visible()
|
|
expect(page.locator("input[name='username']")).to_be_visible()
|
|
expect(page.locator("input[name='password']")).to_be_visible()
|
|
expect(page.locator("input[name='confirm_password']")).to_be_visible()
|
|
expect(page.locator("input[name='email']")).to_be_visible()
|
|
|
|
# Fill out the form
|
|
page.fill("input[name='username']", "e2etestuser")
|
|
page.fill("input[name='password']", "Testpassword123")
|
|
page.fill("input[name='confirm_password']", "Testpassword123")
|
|
page.fill("input[name='email']", "e2etest@example.com")
|
|
|
|
# Submit the form
|
|
expect(page.locator("button[type='submit']")).to_be_visible()
|
|
page.click("button[type='submit']")
|
|
|
|
# Wait for form submission to complete
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
# Since we can't mock the external API calls in E2E tests,
|
|
# we expect this to fail and show an error message
|
|
# In a real environment, this would connect to actual servers
|
|
expect(page.locator("body")).to_contain_text("Error", ignore_case=True)
|
|
|
|
# Note: In a true E2E test environment, you would verify
|
|
# that the user was created in a test media server
|
|
|
|
@patch("app.services.media.service.get_client_for_media_server")
|
|
def test_invitation_form_validation(
|
|
self, mock_get_client, page: Page, live_server, invitation_setup
|
|
):
|
|
"""Test form validation on invitation page."""
|
|
mock_client = create_mock_client(
|
|
"jellyfin", server_id=invitation_setup["server"].id
|
|
)
|
|
mock_get_client.return_value = mock_client
|
|
|
|
# Navigate to invitation page
|
|
page.goto(f"{live_server.url()}{invitation_setup['invitation_url']}")
|
|
|
|
# Click "Accept Invitation" button to show the form
|
|
expect(page.locator("#accept-invite-btn")).to_be_visible()
|
|
page.click("#accept-invite-btn")
|
|
|
|
# Wait for the animation timeline to complete and form fields to become fully visible
|
|
# The animation takes ~800ms + field stagger delays (80ms * 6 fields = 480ms) + buffer
|
|
page.wait_for_timeout(2000)
|
|
|
|
# Wait for form fields to become visible and interactive (with proper CSS opacity)
|
|
page.wait_for_selector(
|
|
"input[name='username']:not([style*='opacity: 0'])", timeout=10000
|
|
)
|
|
page.wait_for_selector(
|
|
"input[name='password']:not([style*='opacity: 0'])", timeout=5000
|
|
)
|
|
page.wait_for_selector(
|
|
"input[name='email']:not([style*='opacity: 0'])", timeout=5000
|
|
)
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
# Test empty form submission - wait for submit button to be clickable
|
|
expect(page.locator("button[type='submit']")).to_be_visible()
|
|
page.click("button[type='submit']")
|
|
|
|
# Client-side validation should prevent submission of empty form
|
|
page.wait_for_load_state("networkidle")
|
|
# Verify we're still on the invitation page (form wasn't submitted)
|
|
expect(page.locator("body")).to_contain_text("been invited")
|
|
|
|
# Test password mismatch - wait for form to be ready
|
|
page.wait_for_selector(
|
|
"input[name='username']:not([style*='opacity: 0'])", timeout=5000
|
|
)
|
|
page.fill("input[name='username']", "testuser")
|
|
page.fill("input[name='password']", "Password123")
|
|
page.fill("input[name='confirm_password']", "Differentpassword123")
|
|
page.fill("input[name='email']", "test@example.com")
|
|
expect(page.locator("button[type='submit']")).to_be_visible()
|
|
page.click("button[type='submit']")
|
|
|
|
# Should show password mismatch error or stay on same page
|
|
page.wait_for_load_state("networkidle")
|
|
expect(page.locator("body")).to_contain_text("been invited")
|
|
|
|
# Form validation test is complete - the form correctly prevented invalid submissions
|
|
|
|
def test_expired_invitation(self, page: Page, live_server, app):
|
|
"""Test that expired invitations show appropriate error."""
|
|
with app.app_context():
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
# Create expired invitation
|
|
server = MediaServer(
|
|
name="Test Server",
|
|
server_type="jellyfin",
|
|
url="http://localhost:8096",
|
|
api_key="test-key",
|
|
)
|
|
db.session.add(server)
|
|
db.session.flush()
|
|
|
|
expired_invitation = Invitation(
|
|
code="EXPIRED123",
|
|
expires=datetime.now(UTC) - timedelta(hours=1),
|
|
used=False,
|
|
)
|
|
expired_invitation.servers = [server]
|
|
db.session.add(expired_invitation)
|
|
db.session.commit()
|
|
|
|
# Navigate to expired invitation
|
|
page.goto(f"{live_server.url()}/j/EXPIRED123")
|
|
|
|
# Should show expiry error
|
|
expect(page.locator("body")).to_contain_text("expired", ignore_case=True)
|
|
|
|
# Form should not be present
|
|
expect(page.locator("form")).not_to_be_visible()
|
|
|
|
def test_invalid_invitation_code(self, page: Page, live_server):
|
|
"""Test that invalid invitation codes show 404 or error page."""
|
|
# Navigate to non-existent invitation
|
|
page.goto(f"{live_server.url()}/j/INVALIDCODE123")
|
|
|
|
# Should show error (either 404 or invalid invitation message)
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
# Check for either 404 or invalid invitation message
|
|
body_text = page.locator("body").inner_text().lower()
|
|
assert any(
|
|
phrase in body_text
|
|
for phrase in ["not found", "invalid", "expired", "does not exist", "404"]
|
|
)
|
|
|
|
@patch("app.services.media.service.get_client_for_media_server")
|
|
def test_server_error_handling(
|
|
self, mock_get_client, page: Page, live_server, invitation_setup
|
|
):
|
|
"""Test handling when media server is unavailable."""
|
|
# Setup failing mock client
|
|
mock_client = create_mock_client(
|
|
"jellyfin", server_id=invitation_setup["server"].id
|
|
)
|
|
mock_client._do_join = lambda *args, **kwargs: (
|
|
False,
|
|
"Server temporarily unavailable",
|
|
)
|
|
mock_get_client.return_value = mock_client
|
|
|
|
# Navigate to invitation page
|
|
page.goto(f"{live_server.url()}{invitation_setup['invitation_url']}")
|
|
|
|
# Click "Accept Invitation" button to show the form
|
|
page.click("#accept-invite-btn")
|
|
# Wait for form fields to become visible (they animate in sequentially)
|
|
page.wait_for_selector(
|
|
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
|
|
timeout=10000,
|
|
)
|
|
|
|
# Fill and submit form
|
|
page.fill("input[name='username']", "erroruser")
|
|
page.fill("input[name='password']", "Testpass123")
|
|
page.fill("input[name='confirm_password']", "Testpass123")
|
|
page.fill("input[name='email']", "error@example.com")
|
|
expect(page.locator("button[type='submit']")).to_be_visible()
|
|
page.click("button[type='submit']")
|
|
|
|
# Should show error (connection refused since no real server running)
|
|
page.wait_for_load_state("networkidle")
|
|
expect(page.locator("body")).to_contain_text("error", ignore_case=True)
|
|
|
|
# After error, we might need to click accept invitation again to show form
|
|
try:
|
|
expect(page.locator("form")).to_be_visible(timeout=2000)
|
|
except AssertionError:
|
|
# If form not visible, try clicking accept invitation button again
|
|
if page.locator("#accept-invite-btn").is_visible():
|
|
page.click("#accept-invite-btn")
|
|
page.wait_for_selector(
|
|
"input[name='username']:not([style*='opacity: 0'])", timeout=10000
|
|
)
|
|
expect(page.locator("form")).to_be_visible()
|
|
|
|
|
|
class TestMultiServerInvitationFlow:
|
|
"""Test E2E flows for multi-server invitations."""
|
|
|
|
@patch("app.services.media.service.get_client_for_media_server")
|
|
def test_multi_server_invitation_success(
|
|
self, mock_get_client, page: Page, live_server, app
|
|
):
|
|
"""Test successful multi-server invitation flow."""
|
|
with app.app_context():
|
|
setup_mock_servers()
|
|
|
|
# Create admin account if it doesn't exist (to bypass setup redirect)
|
|
admin = AdminAccount.query.filter_by(username="testadmin").first()
|
|
changes_made = False
|
|
if not admin:
|
|
admin = AdminAccount(username="testadmin")
|
|
admin.set_password("testpass")
|
|
db.session.add(admin)
|
|
changes_made = True
|
|
|
|
# Create admin_username setting to complete setup (if not exists)
|
|
admin_setting = Settings.query.filter_by(key="admin_username").first()
|
|
if not admin_setting:
|
|
admin_setting = Settings(key="admin_username", value="testadmin")
|
|
db.session.add(admin_setting)
|
|
changes_made = True
|
|
|
|
# Commit admin setup changes if any were made
|
|
if changes_made:
|
|
db.session.commit()
|
|
|
|
# Create multiple non-Plex servers to avoid OAuth flow
|
|
jellyfin_server = MediaServer(
|
|
name="Jellyfin Server",
|
|
server_type="jellyfin",
|
|
url="http://localhost:8096",
|
|
api_key="jellyfin-key",
|
|
)
|
|
emby_server = MediaServer(
|
|
name="Emby Server",
|
|
server_type="emby",
|
|
url="http://localhost:8920",
|
|
api_key="emby-key",
|
|
)
|
|
db.session.add_all([jellyfin_server, emby_server])
|
|
db.session.flush()
|
|
|
|
# Create multi-server invitation with unique code
|
|
invitation = Invitation(
|
|
code="MULTI1",
|
|
duration="30",
|
|
used=False,
|
|
unlimited=False,
|
|
)
|
|
invitation.servers = [jellyfin_server, emby_server]
|
|
db.session.add(invitation)
|
|
db.session.commit()
|
|
|
|
# Setup mock clients
|
|
def get_client_side_effect(server):
|
|
if server.server_type == "jellyfin":
|
|
return create_mock_client("jellyfin", server_id=server.id)
|
|
if server.server_type == "emby":
|
|
return create_mock_client("emby", server_id=server.id)
|
|
return None
|
|
|
|
mock_get_client.side_effect = get_client_side_effect
|
|
|
|
# Navigate to invitation page
|
|
page.goto(f"{live_server.url()}/j/MULTI1")
|
|
|
|
# Wait for page to load
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
# Should show invitation content (may not show specific server names)
|
|
expect(page.locator("body")).to_contain_text("been invited")
|
|
|
|
# Click "Accept Invitation" button to show the form
|
|
page.click("#accept-invite-btn")
|
|
# Wait for form fields to become visible (they animate in sequentially)
|
|
page.wait_for_selector(
|
|
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
|
|
timeout=10000,
|
|
)
|
|
|
|
# Wait for form to be visible
|
|
page.wait_for_selector("input[name='username']", timeout=5000)
|
|
|
|
# Fill and submit the user registration form
|
|
page.fill("input[name='username']", "multiuser")
|
|
page.fill("input[name='password']", "Testpass123")
|
|
page.fill("input[name='confirm_password']", "Testpass123")
|
|
page.fill("input[name='email']", "multi@example.com")
|
|
page.click("button:has-text('Create Account')")
|
|
|
|
# Wait for form submission to complete
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
# Since we can't mock the external API calls in E2E tests with live server,
|
|
# we expect this to fail and show an error message when servers are unreachable
|
|
# In a real environment with running servers, this would succeed
|
|
expect(page.locator("body")).to_contain_text("Error", ignore_case=True)
|
|
|
|
@patch("app.services.media.service.get_client_for_media_server")
|
|
def test_multi_server_partial_failure(
|
|
self, mock_get_client, page: Page, live_server, app
|
|
):
|
|
"""Test multi-server invitation with partial failures."""
|
|
with app.app_context():
|
|
setup_mock_servers()
|
|
|
|
# Create admin account if it doesn't exist (to bypass setup redirect)
|
|
admin = AdminAccount.query.filter_by(username="testadmin").first()
|
|
changes_made = False
|
|
if not admin:
|
|
admin = AdminAccount(username="testadmin")
|
|
admin.set_password("testpass")
|
|
db.session.add(admin)
|
|
changes_made = True
|
|
|
|
# Create admin_username setting to complete setup (if not exists)
|
|
admin_setting = Settings.query.filter_by(key="admin_username").first()
|
|
if not admin_setting:
|
|
admin_setting = Settings(key="admin_username", value="testadmin")
|
|
db.session.add(admin_setting)
|
|
changes_made = True
|
|
|
|
# Commit admin setup changes if any were made
|
|
if changes_made:
|
|
db.session.commit()
|
|
|
|
# Create servers (avoid Plex to skip OAuth)
|
|
jellyfin_server = MediaServer(
|
|
name="Working Server",
|
|
server_type="jellyfin",
|
|
url="http://localhost:8096",
|
|
api_key="working-key",
|
|
)
|
|
broken_server = MediaServer(
|
|
name="Broken Server",
|
|
server_type="emby",
|
|
url="http://localhost:8920",
|
|
api_key="broken-key",
|
|
)
|
|
db.session.add_all([jellyfin_server, broken_server])
|
|
db.session.flush()
|
|
|
|
# Use unique invitation code
|
|
invitation = Invitation(
|
|
code="PARTIAL1",
|
|
duration="30",
|
|
used=False,
|
|
unlimited=False,
|
|
)
|
|
invitation.servers = [jellyfin_server, broken_server]
|
|
db.session.add(invitation)
|
|
db.session.commit()
|
|
|
|
# Setup clients - one working, one failing
|
|
def get_client_side_effect(server):
|
|
if server.name == "Working Server":
|
|
return create_mock_client("jellyfin", server_id=server.id)
|
|
# Create failing Emby client
|
|
client = create_mock_client("emby", server_id=server.id)
|
|
client._do_join = lambda *args, **kwargs: (
|
|
False,
|
|
"Server is down for maintenance",
|
|
)
|
|
return client
|
|
|
|
mock_get_client.side_effect = get_client_side_effect
|
|
|
|
# Navigate and submit
|
|
page.goto(f"{live_server.url()}/j/PARTIAL1")
|
|
# Wait for page to load
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
# Click "Accept Invitation" button to show the form
|
|
page.click("#accept-invite-btn")
|
|
# Wait for form fields to become visible (they animate in sequentially)
|
|
page.wait_for_selector(
|
|
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
|
|
timeout=10000,
|
|
)
|
|
|
|
# Should show invitation content and registration form
|
|
page.wait_for_selector("input[name='username']", timeout=10000)
|
|
page.fill("input[name='username']", "partialuser")
|
|
page.fill("input[name='password']", "Testpass123")
|
|
page.fill("input[name='confirm_password']", "Testpass123")
|
|
page.fill("input[name='email']", "partial@example.com")
|
|
page.click("button:has-text('Create Account')")
|
|
|
|
# Wait for form submission to complete
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
# Since servers are unreachable in E2E environment, expect error message
|
|
# In a real environment with one working and one broken server,
|
|
# this would show partial success
|
|
expect(page.locator("body")).to_contain_text("Error", ignore_case=True)
|
|
|
|
|
|
class TestInvitationUIComponents:
|
|
"""Test UI components and interactions on invitation pages."""
|
|
|
|
def test_invitation_page_accessibility(
|
|
self, page: Page, live_server, invitation_setup
|
|
):
|
|
"""Test basic accessibility of invitation form."""
|
|
page.goto(f"{live_server.url()}{invitation_setup['invitation_url']}")
|
|
|
|
# Click "Accept Invitation" button to show the form
|
|
page.click("#accept-invite-btn")
|
|
# Wait for form fields to become visible (they animate in sequentially)
|
|
page.wait_for_selector(
|
|
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
|
|
timeout=10000,
|
|
)
|
|
|
|
# Check for form labels (they don't have 'for' attributes but are present)
|
|
expect(page.locator("label").filter(has_text="Username")).to_be_visible()
|
|
expect(page.locator("label").filter(has_text="Password").first).to_be_visible()
|
|
expect(page.locator("label").filter(has_text="Email")).to_be_visible()
|
|
|
|
# Check form uses POST method (lowercase is HTML standard)
|
|
expect(page.locator("form")).to_have_attribute("method", "post")
|
|
|
|
# Test keyboard navigation
|
|
page.keyboard.press("Tab") # Should focus first input
|
|
focused_element = page.evaluate(
|
|
"document.activeElement.name || document.activeElement.getAttribute('name')"
|
|
)
|
|
assert (
|
|
focused_element
|
|
in ["username", "password", "email", "confirm_password", "code"]
|
|
or focused_element is None
|
|
)
|
|
|
|
def test_responsive_design(self, page: Page, live_server, invitation_setup):
|
|
"""Test invitation page on different screen sizes."""
|
|
# Test desktop
|
|
page.set_viewport_size({"width": 1920, "height": 1080})
|
|
page.goto(f"{live_server.url()}{invitation_setup['invitation_url']}")
|
|
page.click("#accept-invite-btn")
|
|
# Wait for form fields to become visible
|
|
page.wait_for_selector(
|
|
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
|
|
timeout=10000,
|
|
)
|
|
expect(page.locator("form")).to_be_visible()
|
|
|
|
# Test tablet
|
|
page.set_viewport_size({"width": 768, "height": 1024})
|
|
page.reload()
|
|
page.click("#accept-invite-btn")
|
|
# Wait for form fields to become visible
|
|
page.wait_for_selector(
|
|
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
|
|
timeout=10000,
|
|
)
|
|
expect(page.locator("form")).to_be_visible()
|
|
|
|
# Test mobile
|
|
page.set_viewport_size({"width": 375, "height": 667})
|
|
page.reload()
|
|
page.click("#accept-invite-btn")
|
|
# Wait for form fields to become visible
|
|
page.wait_for_selector(
|
|
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
|
|
timeout=10000,
|
|
)
|
|
expect(page.locator("form")).to_be_visible()
|
|
|
|
# Form should remain usable at all sizes
|
|
expect(page.locator("input[name='username']")).to_be_visible()
|
|
expect(page.locator("button[type='submit']")).to_be_visible()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v", "--headed"]) # Run with visible browser for debugging
|