diff --git a/src/setup.cfg b/src/setup.cfg
index e633a99582..d02ab3b0da 100644
--- a/src/setup.cfg
+++ b/src/setup.cfg
@@ -18,7 +18,13 @@ skip_glob = data/**,make_testdata.py,wsgi.py,bootstrap,celery_app.py,pretix/sett
[tool:pytest]
DJANGO_SETTINGS_MODULE = tests.settings
addopts = -rw
+asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
+# Playwright E2E test configuration
+# Uncomment for debugging: --headed shows browser UI, --slowmo slows operations
+# addopts = -rw --headed --slowmo 500
+# Browser selection (chromium, firefox, webkit)
+# --browser chromium
filterwarnings =
error
ignore:.*invalid escape sequence.*:
diff --git a/src/tests/e2e/README.md b/src/tests/e2e/README.md
new file mode 100644
index 0000000000..8adf1d4502
--- /dev/null
+++ b/src/tests/e2e/README.md
@@ -0,0 +1,487 @@
+# Pretix Widget E2E Tests
+
+End-to-end tests for the pretix widget using Python Playwright and pytest.
+
+## Overview
+
+These tests verify the complete widget experience from a user's perspective, including:
+- Widget embedding and initialization
+- Product browsing and variations
+- Quantity controls and free price inputs
+- Cart management and checkout flows
+- Iframe/new tab integration
+- Responsive behavior and accessibility
+
+## Installation
+
+### Prerequisites
+
+- Python 3.11+
+- Virtual environment activated
+- Django test database configured
+
+### Setup
+
+1. **Activate the virtual environment:**
+ ```bash
+ source env/bin/activate
+ ```
+
+2. **Install pytest-playwright:**
+ ```bash
+ pip install pytest-playwright
+ ```
+
+3. **Install browser binaries:**
+ ```bash
+ # Install all browsers (Chromium, Firefox, WebKit)
+ python -m playwright install
+
+ # Or install specific browsers only
+ python -m playwright install chromium
+ python -m playwright install firefox
+ ```
+
+4. **Verify installation:**
+ ```bash
+ pytest src/tests/e2e/ --collect-only
+ ```
+
+## Running Tests
+
+### Basic Usage
+
+```bash
+# Run all E2E tests
+pytest src/tests/e2e/
+
+# Run specific test file
+pytest src/tests/e2e/test_widget_embedding.py
+
+# Run specific test
+pytest src/tests/e2e/test_widget_cart.py::TestCartBasics::test_add_to_cart_and_open_checkout
+```
+
+### Browser Selection
+
+```bash
+# Run with specific browser (default: chromium)
+pytest src/tests/e2e/ --browser firefox
+pytest src/tests/e2e/ --browser webkit
+
+# Run with multiple browsers
+pytest src/tests/e2e/ --browser chromium --browser firefox
+```
+
+### Debugging Options
+
+```bash
+# Show browser UI (headed mode)
+pytest src/tests/e2e/ --headed
+
+# Slow down operations for observation (milliseconds)
+pytest src/tests/e2e/ --headed --slowmo 1000
+
+# Generate video recordings
+pytest src/tests/e2e/ --video on
+
+# Keep browser open on failure
+pytest src/tests/e2e/ --headed --pdb
+
+# Generate screenshots on failure
+pytest src/tests/e2e/ --screenshot on
+```
+
+### Parallel Execution
+
+```bash
+# Run tests in parallel (requires pytest-xdist)
+pip install pytest-xdist
+pytest src/tests/e2e/ -n 4
+```
+
+### Verbose Output
+
+```bash
+# Show detailed output
+pytest src/tests/e2e/ -v
+
+# Show print statements
+pytest src/tests/e2e/ -s
+
+# Show all captured output even for passing tests
+pytest src/tests/e2e/ -v -s --capture=no
+```
+
+## Test Organization
+
+```
+src/tests/e2e/
+├── conftest.py # Shared fixtures and configuration
+├── test_widget_embedding.py # Widget loading and initialization
+├── test_widget_variations.py # Product variations and expansion
+├── test_widget_quantity_controls.py # Quantity inputs, checkboxes, free price
+└── test_widget_cart.py # Cart management and checkout flow
+```
+
+### Key Fixtures
+
+Defined in `conftest.py`:
+
+- **`widget_organizer`** - Creates test organizer (testorg)
+- **`widget_event`** - Creates test event (testevent)
+- **`widget_items`** - Creates General Admission ($50) and VIP Ticket ($150)
+- **`widget_item_with_variations`** - Creates t-shirt with S/M/L/XL sizes
+- **`widget_item_single_select`** - Creates item with order_max=1 (checkbox)
+- **`widget_item_free_price`** - Creates pay-what-you-want donation item
+- **`widget_item_sold_out`** - Creates sold out item
+- **`widget_event_series`** - Creates event series with 15 subevents
+- **`widget_page`** - Enhanced page object with helper methods
+- **`live_server_url`** - Django live server URL
+
+### WidgetPage Helper Methods
+
+The `widget_page` fixture provides convenient methods:
+
+```python
+def test_example(widget_page, live_server_url, widget_event, widget_items):
+ # Navigate to event
+ widget_page.goto_event(live_server_url, 'testorg', 'testevent')
+
+ # Wait for widget to load
+ widget_page.wait_for_widget_load()
+
+ # Select item quantity
+ widget_page.select_item_quantity('General Admission', 2)
+
+ # Expand variations
+ widget_page.expand_variations('Event T-Shirt')
+
+ # Select variation quantity
+ widget_page.select_variation_quantity('Event T-Shirt', 'Medium', 1)
+
+ # Click buy button
+ widget_page.click_buy_button()
+
+ # Wait for iframe checkout
+ iframe = widget_page.wait_for_iframe_checkout()
+
+ # Close iframe
+ widget_page.close_iframe()
+```
+
+## Common Test Patterns
+
+### Basic Widget Load Test
+
+```python
+@pytest.mark.django_db
+def test_widget_loads(page, live_server_url, widget_organizer, widget_event, widget_items, widget_page):
+ widget_page.goto_event(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Verify content
+ expect(page.locator(f'text="{widget_event.name}"')).to_be_visible()
+```
+
+### Variation Interaction Test
+
+```python
+@pytest.mark.django_db
+def test_variations(page, live_server_url, widget_organizer, widget_event, widget_item_with_variations, widget_page):
+ item, variations = widget_item_with_variations
+
+ widget_page.goto_event(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Expand variations
+ widget_page.expand_variations(item.name)
+
+ # Verify all variations visible
+ for variation in variations:
+ expect(page.locator(f'text="{variation.value}"')).to_be_visible()
+```
+
+### Cart Flow Test
+
+```python
+@pytest.mark.django_db
+def test_checkout(page, context, live_server_url, widget_organizer, widget_event, widget_items, widget_page):
+ widget_page.goto_event(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Add items
+ widget_page.select_item_quantity(widget_items[0].name, 2)
+ widget_page.click_buy_button()
+
+ # Wait for checkout to open
+ page.wait_for_timeout(2000)
+
+ # Verify cookies set
+ cookies = context.cookies()
+ assert len(cookies) > 0
+```
+
+## Debugging
+
+### View Browser in Action
+
+Run tests with `--headed` flag to see the browser:
+
+```bash
+pytest src/tests/e2e/test_widget_embedding.py::TestWidgetEmbedding::test_widget_loads_successfully --headed
+```
+
+### Slow Down for Observation
+
+Use `--slowmo` to slow operations (milliseconds):
+
+```bash
+pytest src/tests/e2e/ --headed --slowmo 1000
+```
+
+### Interactive Debugging
+
+Use `--pdb` to drop into debugger on failure:
+
+```bash
+pytest src/tests/e2e/test_widget_cart.py --headed --pdb
+```
+
+### Record Videos
+
+Generate video recordings of test runs:
+
+```bash
+pytest src/tests/e2e/ --video on
+
+# Videos saved to: test-results/
+```
+
+### Generate Trace Files
+
+For detailed debugging with Playwright Inspector:
+
+```bash
+pytest src/tests/e2e/ --tracing on
+
+# View traces with:
+playwright show-trace test-results/.../trace.zip
+```
+
+## Troubleshooting
+
+### Browser binaries not found
+
+**Error:** `playwright._impl._errors.Error: Executable doesn't exist`
+
+**Solution:**
+```bash
+python -m playwright install chromium
+```
+
+### Django database errors
+
+**Error:** `django.db.utils.OperationalError: no such table`
+
+**Solution:**
+```bash
+# Run migrations in test settings
+DJANGO_SETTINGS_MODULE=tests.settings python manage.py migrate
+```
+
+### Live server not starting
+
+**Error:** `OSError: [Errno 98] Address already in use`
+
+**Solution:** Kill processes using the port or let pytest assign random ports automatically (default behavior).
+
+### Widget not loading
+
+**Issue:** Widget displays loading spinner indefinitely
+
+**Debug steps:**
+1. Check browser console for JavaScript errors:
+ ```python
+ page.on("console", lambda msg: print(f"Console: {msg.text}"))
+ ```
+
+2. Verify widget JavaScript is built:
+ ```bash
+ ls -l src/pretix/static/pretixpresale/widget/dist/
+ ```
+
+3. Check Django static files are served:
+ ```bash
+ DJANGO_SETTINGS_MODULE=tests.settings python manage.py collectstatic --noinput
+ ```
+
+### Timeout errors
+
+**Error:** `playwright._impl._errors.TimeoutError: Timeout 10000ms exceeded`
+
+**Solution:** Increase timeout for specific operations:
+```python
+page.wait_for_selector('.pretix-widget', timeout=30000)
+```
+
+Or globally in conftest.py:
+```python
+@pytest.fixture
+def page(context):
+ page = context.new_page()
+ page.set_default_timeout(30000) # 30 seconds
+ yield page
+ page.close()
+```
+
+### Cookie issues
+
+**Issue:** Cart cookies not being set
+
+**Debug:**
+```python
+# Print all cookies
+cookies = context.cookies()
+for cookie in cookies:
+ print(f"{cookie['name']}: {cookie['value']}")
+```
+
+Check domain/path settings match your test server.
+
+## CI/CD Integration
+
+### GitHub Actions Example
+
+```yaml
+name: E2E Tests
+
+on: [push, pull_request]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: |
+ pip install -r requirements.txt
+ pip install pytest-playwright
+ python -m playwright install --with-deps chromium
+
+ - name: Run E2E tests
+ run: |
+ pytest src/tests/e2e/ --browser chromium --video on
+
+ - name: Upload test artifacts
+ if: always()
+ uses: actions/upload-artifact@v3
+ with:
+ name: test-results
+ path: test-results/
+```
+
+## Configuration
+
+### setup.cfg
+
+Playwright configuration is in `/home/rash/Projects/pretix/src/setup.cfg`:
+
+```ini
+[tool:pytest]
+DJANGO_SETTINGS_MODULE = tests.settings
+addopts = -rw
+# Uncomment for debugging:
+# addopts = -rw --headed --slowmo 500
+# --browser chromium
+```
+
+### Browser Context Args
+
+Customize in `conftest.py`:
+
+```python
+@pytest.fixture(scope="session")
+def browser_context_args(browser_context_args):
+ return {
+ **browser_context_args,
+ "viewport": {"width": 1280, "height": 720},
+ "locale": "en-US",
+ "timezone_id": "America/New_York",
+ }
+```
+
+## Writing New Tests
+
+### Test Structure
+
+```python
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestFeatureName:
+ """Test description."""
+
+ def test_specific_behavior(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Test should description.
+
+ Given: initial state
+ When: action occurs
+ Then: expected outcome
+ """
+ # Arrange
+ widget_page.goto_event(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Act
+ widget_page.select_item_quantity(widget_items[0].name, 1)
+
+ # Assert
+ expect(page.locator('.some-element')).to_be_visible()
+```
+
+### Best Practices
+
+1. **Use descriptive test names** - `test_variation_quantity_updates_on_input` vs `test_var`
+2. **Use WidgetPage helpers** - Encapsulate common interactions
+3. **Wait for elements** - Use `expect().to_be_visible()` instead of `wait_for_timeout()`
+4. **Mark Django tests** - Always use `@pytest.mark.django_db` when accessing database
+5. **Clean test data** - Use fixtures, let pytest handle cleanup
+6. **Verify user-visible behavior** - Test what users see, not implementation details
+
+## Related Documentation
+
+- [Playwright Python Docs](https://playwright.dev/python/)
+- [pytest-playwright Plugin](https://github.com/microsoft/playwright-pytest)
+- [Pretix Widget Documentation](https://docs.pretix.eu/guides/widget/)
+- [Plan Document](/home/rash/.claude/plans/snazzy-wishing-horizon.md) - Complete test specification
+
+## Test Coverage
+
+Current test files cover Phase 1 (Critical Path):
+
+- ✅ Widget embedding & initialization (6 tests)
+- ✅ Product variations (7 tests)
+- ✅ Quantity controls & free price (11 tests)
+- ✅ Cart management & checkout (7 tests)
+
+**Total: 31 tests implemented**
+
+See plan document for complete 130-test specification covering all widget features.
diff --git a/src/tests/e2e/conftest.py b/src/tests/e2e/conftest.py
new file mode 100644
index 0000000000..c53f200fb2
--- /dev/null
+++ b/src/tests/e2e/conftest.py
@@ -0,0 +1,1146 @@
+"""
+E2E Test Configuration for Pretix Widget with Playwright
+
+This module provides pytest fixtures for end-to-end testing of the pretix widget
+using Playwright. It integrates Playwright with Django's test infrastructure.
+"""
+import os
+import pytest
+from decimal import Decimal
+from datetime import datetime, timezone, timedelta
+from playwright.sync_api import Browser, BrowserContext, Page, expect
+from django_scopes import scopes_disabled
+
+from pretix.base.models import (
+ Organizer, Event, Item, Quota, ItemVariation, SubEvent, Voucher
+)
+
+# Allow Django ORM operations in async context (required for Playwright integration)
+os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
+
+
+# ============================================================================
+# Playwright Configuration
+# ============================================================================
+
+@pytest.fixture(scope="session")
+def browser_context_args(browser_context_args):
+ """Configure browser context for all tests."""
+ return {
+ **browser_context_args,
+ "viewport": {"width": 1280, "height": 720},
+ "locale": "en-US",
+ "timezone_id": "America/New_York",
+ # Enable video recording for debugging (optional)
+ # "record_video_dir": "test-results/videos/",
+ }
+
+
+@pytest.fixture(scope="session")
+def browser_type_launch_args(browser_type_launch_args):
+ """Configure browser launch arguments."""
+ return {
+ **browser_type_launch_args,
+ # Uncomment for debugging
+ # "headless": False,
+ # "slow_mo": 500, # Slow down operations by 500ms
+ }
+
+
+# ============================================================================
+# Django Live Server Fixtures
+# ============================================================================
+
+@pytest.fixture
+def live_server_url(live_server, settings):
+ """
+ Get the live server URL.
+
+ Uses pytest-django's built-in live_server fixture which starts
+ a Django development server for E2E tests.
+ """
+ # Enable django-compressor for on-the-fly SCSS compilation
+ settings.COMPRESS_ENABLED = True
+ settings.COMPRESS_OFFLINE = False # Compile on-the-fly, not from cache
+
+ # Re-enable SCSS precompilers (disabled in test settings)
+ from pretix.testutils.settings import COMPRESS_PRECOMPILERS_ORIGINAL
+ settings.COMPRESS_PRECOMPILERS = COMPRESS_PRECOMPILERS_ORIGINAL
+
+ # Fix cache backend for compression
+ settings.COMPRESS_CACHE_BACKEND = 'default'
+
+ # Add testcache to CACHES if needed (for compatibility)
+ if 'testcache' not in settings.CACHES:
+ settings.CACHES['testcache'] = {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ }
+
+ # Fix SITE_URL to point to live server instead of example.com.
+ # This makes build_absolute_uri() return localhost URLs so the widget's
+ # target_url (from the API response) resolves to the live server.
+ # Without this, form submissions (cart/add) go to example.com.
+ settings.SITE_URL = live_server.url
+
+ return live_server.url
+
+
+@pytest.fixture(scope='session', autouse=True)
+def _register_widget_test_view():
+ """
+ Register a test view that serves an HTML page with widget embedded.
+
+ This allows E2E tests to navigate to a real URL instead of using
+ set_content, which causes CORS issues.
+ """
+ from django.http import HttpResponse
+ from django.views import View
+ from django.urls import path
+ from pretix.multidomain import maindomain_urlconf as urls
+
+ class WidgetTestView(View):
+ """Serve HTML page with widget embedded for E2E testing."""
+
+ # Widget attributes that can be passed as query params
+ WIDGET_ATTRS = [
+ 'items', 'categories', 'voucher', 'disable-vouchers',
+ 'disable-iframe', 'subevent', 'list-type',
+ 'display-event-info', 'skip-ssl-check',
+ ]
+
+ def get(self, request, organizer, event):
+ # Build URLs exactly as in working index.html
+ base_url = f"{request.scheme}://{request.get_host()}"
+ event_url = f"{base_url}/{organizer}/{event}/"
+ # CSS is scoped to event, JS is at root
+ widget_css = f"{base_url}/{organizer}/{event}/widget/v2.css"
+ widget_js = f"{base_url}/widget/v2.en.js"
+
+ # Build extra attributes from query params
+ extra_attrs = ''
+ for attr in self.WIDGET_ATTRS:
+ val = request.GET.get(attr)
+ if val is not None:
+ if val == '':
+ # Boolean attribute (e.g., disable-vouchers)
+ extra_attrs += f' {attr}'
+ else:
+ extra_attrs += f' {attr}="{val}"'
+
+ # Always add skip-ssl-check so iframe checkout works on HTTP
+ if 'skip-ssl-check' not in extra_attrs:
+ extra_attrs += ' skip-ssl-check'
+
+ html = f"""
+
+
+
+
+ Widget Test
+
+
+
+
+
+
+"""
+ resp = HttpResponse(html, content_type='text/html')
+ resp['Content-Security-Policy'] = "script-src * 'unsafe-inline' 'unsafe-eval'; style-src * 'unsafe-inline'"
+ return resp
+
+ class ButtonTestView(View):
+ """Serve HTML page with pretix-button element for E2E testing."""
+
+ def get(self, request, organizer, event):
+ base_url = f"{request.scheme}://{request.get_host()}"
+ event_url = f"{base_url}/{organizer}/{event}/"
+ widget_css = f"{base_url}/{organizer}/{event}/widget/v2.css"
+ widget_js = f"{base_url}/widget/v2.en.js"
+
+ # Build extra attributes from query params
+ extra_attrs = ''
+ for attr in ['items', 'voucher', 'subevent', 'disable-iframe']:
+ val = request.GET.get(attr)
+ if val is not None:
+ if val == '':
+ extra_attrs += f' {attr}'
+ else:
+ extra_attrs += f' {attr}="{val}"'
+
+ button_text = request.GET.get('button-text', 'Buy tickets!')
+
+ html = f"""
+
+
+
+
+ Button Test
+
+
+
+ {button_text}
+
+
+"""
+ resp = HttpResponse(html, content_type='text/html')
+ resp['Content-Security-Policy'] = "script-src * 'unsafe-inline' 'unsafe-eval'; style-src * 'unsafe-inline'"
+ return resp
+
+ # Add URL patterns
+ test_pattern = path(
+ 'widget-test///',
+ WidgetTestView.as_view()
+ )
+ button_pattern = path(
+ 'button-test///',
+ ButtonTestView.as_view()
+ )
+
+ # Insert at beginning of URL patterns
+ if hasattr(urls, 'urlpatterns'):
+ urls.urlpatterns.insert(0, test_pattern)
+ urls.urlpatterns.insert(0, button_pattern)
+
+
+# ============================================================================
+# Test Data Fixtures - Organizers and Events
+# ============================================================================
+
+@pytest.fixture
+@scopes_disabled()
+def widget_organizer(db):
+ """
+ Create an organizer for widget tests.
+ Reuses the same pattern as existing API tests.
+ """
+ return Organizer.objects.create(
+ name='Test Organizer',
+ slug='testorg',
+ plugins='pretix.plugins.banktransfer,pretix.plugins.stripe'
+ )
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_event(widget_organizer):
+ """Create a basic event for widget tests."""
+ event = Event.objects.create(
+ organizer=widget_organizer,
+ name='Test Event',
+ slug='testevent',
+ date_from=datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc),
+ date_to=datetime(2026, 6, 1, 18, 0, 0, tzinfo=timezone.utc),
+ currency='USD',
+ live=True,
+ testmode=False,
+ plugins='pretix.plugins.banktransfer',
+ )
+ event.settings.set('timezone', 'America/New_York')
+ event.settings.set('locale', 'en')
+ event.settings.set('locales', ['en'])
+ return event
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_items(widget_event):
+ """Create basic test items/products."""
+ from pretix.base.models import ItemCategory
+
+ items = []
+
+ # Create a proper category
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Tickets',
+ position=0
+ )
+
+ # General Admission ticket
+ item1 = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='General Admission',
+ default_price=Decimal('50.00'),
+ description='Standard entry ticket',
+ active=True,
+ )
+ items.append(item1)
+
+ # VIP ticket
+ item2 = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='VIP Ticket',
+ default_price=Decimal('150.00'),
+ description='VIP access with special perks',
+ active=True,
+ )
+ items.append(item2)
+
+ # Create quotas for each item
+ for item in items:
+ quota = Quota.objects.create(
+ event=widget_event,
+ name=f'{item.name} Quota',
+ size=100,
+ )
+ quota.items.add(item)
+
+ return items
+
+
+# ============================================================================
+# Test Data Fixtures - Items with Variations
+# ============================================================================
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_with_variations(widget_event):
+ """Create an item with size variations (S, M, L, XL)."""
+ from pretix.base.models import ItemCategory
+
+ # Create category for the item
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Merchandise',
+ position=1
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Event T-Shirt',
+ default_price=Decimal('25.00'),
+ description='Official event t-shirt',
+ active=True,
+ )
+
+ variations = []
+ sizes_and_prices = [
+ ('Small', Decimal('20.00')),
+ ('Medium', Decimal('25.00')),
+ ('Large', Decimal('25.00')),
+ ('X-Large', Decimal('30.00')),
+ ]
+
+ for size, price in sizes_and_prices:
+ var = ItemVariation.objects.create(
+ item=item,
+ value=size,
+ default_price=price,
+ )
+ variations.append(var)
+
+ # Create quota for all variations
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='T-Shirt Quota',
+ size=50,
+ )
+ # Add both the item AND the variations to the quota
+ quota.items.add(item)
+ for var in variations:
+ quota.variations.add(var)
+
+ return item, variations
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_single_select(widget_event):
+ """Create an item with max_per_order=1 (should show checkbox)."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='VIP',
+ position=2
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='VIP Pass',
+ default_price=Decimal('500.00'),
+ description='Limited VIP pass - one per customer',
+ active=True,
+ max_per_order=1,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='VIP Quota',
+ size=10,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_free_price(widget_event):
+ """Create an item with pay-what-you-want pricing."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Donations',
+ position=3
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Donation',
+ default_price=Decimal('10.00'),
+ description='Support our cause',
+ active=True,
+ free_price=True,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Donation Quota',
+ size=999,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_sold_out(widget_event):
+ """Create a sold out item."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Early Bird',
+ position=4
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Early Bird Ticket',
+ default_price=Decimal('30.00'),
+ description='Sold out!',
+ active=True,
+ )
+
+ # Create quota with size=0 (sold out)
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Early Bird Quota',
+ size=0,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_free(widget_event):
+ """Create a free item (price = 0.00)."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Free Stuff',
+ position=10
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Free Gift',
+ default_price=Decimal('0.00'),
+ active=True,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Free Gift Quota',
+ size=100,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_with_decimals(widget_event):
+ """Create an item with non-zero decimal price."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Test Category',
+ position=11
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Half Price Item',
+ default_price=Decimal('12.50'),
+ active=True,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Half Price Quota',
+ size=50,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_with_tax(widget_event):
+ """Create an item with tax rule."""
+ from pretix.base.models import ItemCategory, TaxRule
+
+ # Create tax rule
+ tax_rule = TaxRule.objects.create(
+ event=widget_event,
+ name='VAT',
+ rate=Decimal('19.00'), # 19% VAT
+ )
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Taxed Items',
+ position=12
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Taxed Product',
+ default_price=Decimal('100.00'),
+ tax_rule=tax_rule,
+ active=True,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Taxed Product Quota',
+ size=50,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+# ============================================================================
+# Test Data Fixtures - Edge Cases
+# ============================================================================
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_min_order(widget_event):
+ """Create an item with min_per_order=2."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Group Tickets',
+ position=13
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Group Pass',
+ default_price=Decimal('40.00'),
+ active=True,
+ min_per_order=2,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Group Pass Quota',
+ size=50,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_special_chars(widget_event):
+ """Create an item with special characters in the name."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Spezial',
+ position=14
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Böhm & Söhne Konzert',
+ default_price=Decimal('55.00'),
+ active=True,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Special Quota',
+ size=50,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+# ============================================================================
+# Test Data Fixtures - Categories
+# ============================================================================
+
+@pytest.fixture
+@scopes_disabled()
+def widget_items_with_category_description(widget_event):
+ """Create items with a category that has a description."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Tickets',
+ description='Early bird tickets available',
+ position=0
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Early Bird',
+ default_price=Decimal('35.00'),
+ active=True,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Early Bird Quota',
+ size=100,
+ )
+ quota.items.add(item)
+
+ return [item]
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_items_multiple_categories(widget_event):
+ """Create items in multiple categories to test grouping and ordering."""
+ from pretix.base.models import ItemCategory
+
+ cat_music = ItemCategory.objects.create(
+ event=widget_event,
+ name='Music',
+ position=0
+ )
+
+ cat_food = ItemCategory.objects.create(
+ event=widget_event,
+ name='Food & Drink',
+ position=1
+ )
+
+ item1 = Item.objects.create(
+ event=widget_event,
+ category=cat_music,
+ name='Concert Ticket',
+ default_price=Decimal('75.00'),
+ active=True,
+ )
+
+ item2 = Item.objects.create(
+ event=widget_event,
+ category=cat_food,
+ name='Food Pass',
+ default_price=Decimal('25.00'),
+ active=True,
+ )
+
+ for item in [item1, item2]:
+ quota = Quota.objects.create(
+ event=widget_event,
+ name=f'{item.name} Quota',
+ size=100,
+ )
+ quota.items.add(item)
+
+ return [item1, item2]
+
+
+# ============================================================================
+# Test Data Fixtures - Vouchers
+# ============================================================================
+
+@pytest.fixture
+@scopes_disabled()
+def widget_voucher(widget_event, widget_items):
+ """Create a voucher for the event."""
+ voucher = Voucher.objects.create(
+ event=widget_event,
+ code='TESTCODE2024',
+ max_usages=10,
+ price_mode='none',
+ )
+ # Clear the vouchers_exist cache so the widget picks it up
+ widget_event.get_cache().delete('vouchers_exist')
+ return voucher
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_voucher_with_item(widget_event, widget_items):
+ """Create a voucher tied to a specific item."""
+ item = widget_items[0]
+ voucher = Voucher.objects.create(
+ event=widget_event,
+ code='ITEMVOUCHER',
+ max_usages=5,
+ price_mode='percent',
+ value=Decimal('20.00'), # 20% off
+ item=item,
+ )
+ widget_event.get_cache().delete('vouchers_exist')
+ return voucher
+
+
+# ============================================================================
+# Test Data Fixtures - Waiting List
+# ============================================================================
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_sold_out_with_waitinglist(widget_event):
+ """Create a sold out item with waiting list enabled."""
+ from pretix.base.models import ItemCategory
+
+ # Enable waiting list on the event
+ widget_event.settings.set('waiting_list_enabled', True)
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Sold Out',
+ position=20
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Sold Out Concert',
+ default_price=Decimal('80.00'),
+ active=True,
+ allow_waitinglist=True,
+ )
+
+ # Create quota with size=0 (sold out)
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Sold Out Quota',
+ size=0,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+# ============================================================================
+# Test Data Fixtures - Event Series
+# ============================================================================
+
+@pytest.fixture
+@scopes_disabled()
+def widget_event_series(widget_organizer):
+ """Create an event series with multiple subevents, items, and quotas."""
+ from pretix.base.models import ItemCategory
+
+ event = Event.objects.create(
+ organizer=widget_organizer,
+ name='Concert Series',
+ slug='concert-series',
+ date_from=datetime(2026, 6, 1, 19, 0, 0, tzinfo=timezone.utc),
+ has_subevents=True,
+ currency='USD',
+ live=True,
+ plugins='pretix.plugins.banktransfer',
+ )
+ event.settings.set('timezone', 'America/New_York')
+ event.settings.set('locale', 'en')
+ event.settings.set('locales', ['en'])
+
+ # Create item + category for the series
+ category = ItemCategory.objects.create(
+ event=event,
+ name='Tickets',
+ position=0
+ )
+ item = Item.objects.create(
+ event=event,
+ category=category,
+ name='Concert Ticket',
+ default_price=Decimal('45.00'),
+ active=True,
+ )
+
+ # Create multiple subevents (dates)
+ subevents = []
+ base_date = datetime(2026, 6, 1, 19, 0, 0, tzinfo=timezone.utc)
+
+ for i in range(15): # Create 15 dates across 2 months
+ se = SubEvent.objects.create(
+ event=event,
+ name=f'Concert Night {i+1}',
+ date_from=base_date + timedelta(days=i*2),
+ date_to=base_date + timedelta(days=i*2, hours=3),
+ active=True,
+ )
+ subevents.append(se)
+
+ # Each subevent needs its own quota
+ quota = Quota.objects.create(
+ event=event,
+ name=f'Concert {i+1} Quota',
+ size=100,
+ subevent=se,
+ )
+ quota.items.add(item)
+
+ return event, subevents
+
+
+# ============================================================================
+# Widget Helper Fixtures
+# ============================================================================
+
+@pytest.fixture
+def widget_page(page):
+ """
+ Enhanced page fixture with widget-specific helper methods.
+
+ Provides convenience methods for common widget interactions.
+ """
+ class WidgetPage:
+ def __init__(self, page: Page):
+ self.page = page
+
+ def goto_widget_test_page(
+ self,
+ live_server_url: str,
+ org_slug: str,
+ event_slug: str,
+ **widget_attrs
+ ):
+ """
+ Navigate to a test page with widget embedded.
+
+ Uses a Django view that serves an HTML page with the pretix
+ widget embedded, simulating how it would be used on a customer's
+ website.
+
+ Extra keyword arguments are passed as query params to the view,
+ which converts them to widget attributes. For boolean attributes
+ (like disable-vouchers), pass an empty string as value.
+ """
+ # Navigate to the test view URL
+ test_url = f"{live_server_url}/widget-test/{org_slug}/{event_slug}/"
+ if widget_attrs:
+ from urllib.parse import urlencode
+ test_url += '?' + urlencode(widget_attrs)
+ self.page.goto(test_url)
+ return self
+
+ def goto_event(self, live_server_url: str, org_slug: str, event_slug: str):
+ """Navigate to an event page."""
+ self.page.goto(f"{live_server_url}/{org_slug}/{event_slug}/")
+ return self
+
+ def wait_for_widget_load(self):
+ """Wait for widget to finish loading."""
+ # Wait for widget element with longer timeout
+ self.page.wait_for_selector('.pretix-widget', timeout=15000)
+ # Give it a moment to render content
+ self.page.wait_for_timeout(1000)
+ return self
+
+ def select_item_quantity(self, item_name: str, quantity: int):
+ """Select quantity for an item by name."""
+ # Find the item row
+ item_row = self.page.locator(f'.pretix-widget-item:has-text("{item_name}")')
+
+ # Find number input within that row
+ number_input = item_row.locator('input[type="number"]').first
+ if number_input.count() > 0:
+ number_input.fill(str(quantity))
+ else:
+ # Maybe it's a checkbox (order_max=1)
+ checkbox = item_row.locator('input[type="checkbox"]').first
+ if quantity > 0:
+ checkbox.check()
+ return self
+
+ def select_variation_quantity(self, item_name: str, variation_name: str, quantity: int):
+ """Select quantity for a specific variation."""
+ # Find item
+ item = self.page.locator(f'.pretix-widget-item:has-text("{item_name}")')
+
+ # Find variation within item using exact text match to avoid
+ # "Large" matching "X-Large"
+ variation = item.locator(
+ f'.pretix-widget-variation:has(strong:text-is("{variation_name}"))'
+ )
+
+ # Find input
+ input_field = variation.locator('input[type="number"]').first
+ input_field.fill(str(quantity))
+ return self
+
+ def click_buy_button(self):
+ """Click the buy/register button."""
+ buy_button = self.page.locator('button:has-text("Buy"), button:has-text("Register")')
+ buy_button.first.click()
+ return self
+
+ def wait_for_iframe_checkout(self):
+ """Wait for checkout iframe to appear."""
+ self.page.wait_for_selector('.pretix-widget-frame-shown', timeout=15000)
+ # Wait for iframe to load
+ self.page.wait_for_function(
+ """() => {
+ const iframe = document.querySelector('iframe[name^="pretix-widget-"]');
+ return iframe && iframe.src !== 'about:blank';
+ }""",
+ timeout=15000
+ )
+ iframe = self.page.frame_locator('iframe[name^="pretix-widget-"]')
+ return iframe
+
+ def close_iframe(self):
+ """Close the checkout iframe."""
+ close_btn = self.page.locator('.pretix-widget-frame-close button')
+ close_btn.click()
+ # Wait for iframe to close
+ self.page.wait_for_function(
+ """() => {
+ const frame = document.querySelector('.pretix-widget-frame-shown');
+ return !frame || !frame.classList.contains('pretix-widget-frame-shown');
+ }""",
+ timeout=5000
+ )
+ return self
+
+ def expand_variations(self, item_name: str):
+ """Click the 'Show variants' button for an item."""
+ item = self.page.locator(f'.pretix-widget-item:has-text("{item_name}")')
+ toggle_btn = item.locator('button:has-text("Show variants"), button:has-text("variants")')
+ toggle_btn.click()
+ return self
+
+ def goto_button_test_page(
+ self,
+ live_server_url: str,
+ org_slug: str,
+ event_slug: str,
+ **query_params
+ ):
+ """Navigate to a test page with pretix-button embedded."""
+ from urllib.parse import urlencode
+ test_url = f"{live_server_url}/button-test/{org_slug}/{event_slug}/"
+ if query_params:
+ test_url += '?' + urlencode(query_params)
+ self.page.goto(test_url)
+ return self
+
+ return WidgetPage(page)
+
+
+# ============================================================================
+# Test Data Fixtures - Availability States
+# ============================================================================
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_require_voucher(widget_event):
+ """Create an item that requires a voucher to purchase."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Voucher Only',
+ position=30
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Exclusive Pass',
+ default_price=Decimal('200.00'),
+ active=True,
+ require_voucher=True,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Exclusive Quota',
+ size=50,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_low_stock(widget_event):
+ """Create an item with low stock (quota_left visible)."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Limited',
+ position=31
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Last Chance Ticket',
+ default_price=Decimal('65.00'),
+ active=True,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Limited Quota',
+ size=3,
+ )
+ quota.items.add(item)
+
+ # Enable "show quota left" on the event
+ widget_event.settings.set('show_quota_left', True)
+
+ return item
+
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_not_yet_available(widget_event):
+ """Create an item that is not yet available (future available_from)."""
+ from pretix.base.models import ItemCategory
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Coming Soon',
+ position=32
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Future Ticket',
+ default_price=Decimal('45.00'),
+ active=True,
+ available_from=datetime(2099, 1, 1, tzinfo=timezone.utc),
+ available_from_mode='info', # Show as "not yet available" instead of hiding
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Future Quota',
+ size=100,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+# ============================================================================
+# Test Data Fixtures - Items with Pictures
+# ============================================================================
+
+@pytest.fixture
+@scopes_disabled()
+def widget_item_with_picture(widget_event):
+ """Create an item with a product picture."""
+ from pretix.base.models import ItemCategory
+ from django.core.files.uploadedfile import SimpleUploadedFile
+ import io
+ from PIL import Image as PILImage
+
+ category = ItemCategory.objects.create(
+ event=widget_event,
+ name='Gallery Items',
+ position=40
+ )
+
+ # Create a small test image (100x100 red square)
+ img = PILImage.new('RGB', (100, 100), color='red')
+ buf = io.BytesIO()
+ img.save(buf, format='PNG')
+ buf.seek(0)
+
+ picture_file = SimpleUploadedFile(
+ name='test_product.png',
+ content=buf.read(),
+ content_type='image/png'
+ )
+
+ item = Item.objects.create(
+ event=widget_event,
+ category=category,
+ name='Art Print',
+ default_price=Decimal('35.00'),
+ description='Limited edition art print',
+ active=True,
+ picture=picture_file,
+ )
+
+ quota = Quota.objects.create(
+ event=widget_event,
+ name='Art Print Quota',
+ size=50,
+ )
+ quota.items.add(item)
+
+ return item
+
+
+# ============================================================================
+# Cross-Browser Testing
+# ============================================================================
+
+@pytest.fixture(params=['chromium']) # Add 'firefox', 'webkit' when ready
+def cross_browser_page(request, playwright):
+ """
+ Test across multiple browsers.
+
+ Usage:
+ def test_widget_works_everywhere(cross_browser_page):
+ page = cross_browser_page
+ page.goto("...")
+ """
+ browser_type = getattr(playwright, request.param)
+ browser = browser_type.launch()
+ context = browser.new_context()
+ page = context.new_page()
+
+ yield page
+
+ page.close()
+ context.close()
+ browser.close()
diff --git a/src/tests/e2e/test_css_check.py b/src/tests/e2e/test_css_check.py
new file mode 100644
index 0000000000..706add970d
--- /dev/null
+++ b/src/tests/e2e/test_css_check.py
@@ -0,0 +1,63 @@
+"""
+Test to verify CSS is properly compiled without SCSS syntax.
+"""
+import pytest
+from playwright.sync_api import Page
+
+
+@pytest.mark.django_db
+def test_css_contains_no_scss_syntax(
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items
+):
+ """Verify CSS is compiled and doesn't contain SCSS syntax."""
+ # Fetch the CSS directly
+ css_url = f"{live_server_url}/{widget_organizer.slug}/{widget_event.slug}/widget/v2.css"
+
+ print(f"\nFetching CSS from: {css_url}")
+ response = page.request.get(css_url)
+
+ print(f"Status: {response.status}")
+ assert response.status == 200, f"CSS returned {response.status}"
+
+ css_content = response.text()
+
+ # Check that CSS doesn't contain SCSS syntax
+ scss_indicators = [
+ '@include', # SCSS mixins
+ '@extend', # SCSS extends
+ '$', # SCSS variables (though $ can appear in selectors, check more carefully)
+ ]
+
+ print(f"\nCSS length: {len(css_content)} characters")
+ print(f"\nFirst 500 chars of CSS:\n{css_content[:500]}")
+
+ has_scss = False
+ for indicator in scss_indicators:
+ if indicator in css_content:
+ # For $, be more specific - check if it's actually a variable
+ if indicator == '$':
+ # Look for variable patterns like $variable-name or $variable_name
+ import re
+ if re.search(r'\$[a-zA-Z_]', css_content):
+ has_scss = True
+ print(f"\n⚠️ Found SCSS syntax: {indicator}")
+ # Find and print examples
+ matches = re.findall(r'\$[a-zA-Z_][a-zA-Z0-9_-]*', css_content)
+ print(f"Examples: {matches[:5]}")
+ else:
+ has_scss = True
+ print(f"\n⚠️ Found SCSS syntax: {indicator}")
+ # Find line with the indicator
+ for i, line in enumerate(css_content.split('\n')[:100]):
+ if indicator in line:
+ print(f"Line {i+1}: {line}")
+ break
+
+ if not has_scss:
+ print("\n✅ CSS is properly compiled - no SCSS syntax found!")
+
+ assert not has_scss, "CSS contains SCSS syntax - not properly compiled!"
diff --git a/src/tests/e2e/test_views.py b/src/tests/e2e/test_views.py
new file mode 100644
index 0000000000..b128af7c30
--- /dev/null
+++ b/src/tests/e2e/test_views.py
@@ -0,0 +1,50 @@
+"""
+Test views for E2E widget testing.
+
+These views serve HTML pages with embedded widgets for testing purposes.
+"""
+from django.http import HttpResponse
+from django.template import Template, Context
+from django.views import View
+
+
+class WidgetTestPageView(View):
+ """
+ Serves a simple HTML page with the pretix widget embedded.
+
+ Used for E2E testing to simulate how the widget would be embedded
+ in a customer's website.
+ """
+
+ def get(self, request, organizer, event):
+ """Render test page with widget embedded."""
+ # Build event URL
+ event_url = request.build_absolute_uri(
+ f"/{organizer}/{event}/"
+ )
+
+ # Build widget script URL (old version)
+ widget_script_url = request.build_absolute_uri(
+ f"/widget/v2.en.js"
+ )
+
+ html_content = f"""
+
+
+
+
+ Widget Test Page
+
+
+ Pretix Widget E2E Test Page
+
+
+
+
+
+
+
+
+"""
+
+ return HttpResponse(html_content, content_type="text/html")
diff --git a/src/tests/e2e/test_widget_accessibility.py b/src/tests/e2e/test_widget_accessibility.py
new file mode 100644
index 0000000000..efbaa487d9
--- /dev/null
+++ b/src/tests/e2e/test_widget_accessibility.py
@@ -0,0 +1,313 @@
+"""
+E2E Tests for Accessibility
+
+Tests that verify:
+- ARIA labels on main widget wrapper
+- Heading roles and levels
+- Voucher input labeling
+- Buy button aria-describedby
+- Keyboard navigation
+- Quantity control labels
+- Calendar table accessibility
+- Variations toggle aria-expanded/aria-controls
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestWidgetAriaLabels:
+ """Test ARIA attributes on the widget structure."""
+
+ def test_widget_wrapper_has_role_article(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Main widget wrapper should have role="article"
+ and aria-label with event name.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ wrapper = page.locator('.pretix-widget-wrapper')
+ expect(wrapper).to_have_attribute('role', 'article')
+ expect(wrapper).to_have_attribute('aria-label', widget_event.name)
+
+ def test_widget_wrapper_is_focusable(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget wrapper should have tabindex="0" for keyboard access.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ wrapper = page.locator('.pretix-widget-wrapper')
+ expect(wrapper).to_have_attribute('tabindex', '0')
+
+ def test_event_name_has_heading_role(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Event name heading in event form should have role="heading"
+ with aria-level="2".
+
+ Note: Event header is only shown when display_event_info
+ is explicitly enabled for single events (auto mode hides it).
+ We use the display-event-info attribute to force it on.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ widget_event.slug,
+ **{'display-event-info': 'true'}
+ )
+ widget_page.wait_for_widget_load()
+
+ heading = page.locator(
+ '.pretix-widget-event-header strong[role="heading"]')
+ expect(heading).to_be_visible()
+ expect(heading).to_have_attribute('aria-level', '2')
+
+
+@pytest.mark.django_db
+class TestQuantityControlAccessibility:
+ """Test accessibility of quantity controls."""
+
+ def test_increment_button_has_aria_label(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Plus/minus buttons should have descriptive aria-labels.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Find increment button for first item
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_items[0].name}")')
+ inc_btn = item_elem.locator('button[aria-label]').last
+ dec_btn = item_elem.locator('button[aria-label]').first
+
+ # Should have aria-labels
+ inc_label = inc_btn.get_attribute('aria-label')
+ dec_label = dec_btn.get_attribute('aria-label')
+ assert inc_label is not None and len(inc_label) > 0
+ assert dec_label is not None and len(dec_label) > 0
+
+ def test_quantity_input_has_aria_labelledby(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Quantity input should be connected to a label via aria-labelledby.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_items[0].name}")')
+ qty_input = item_elem.locator('input[type="number"]')
+
+ labelledby = qty_input.get_attribute('aria-labelledby')
+ assert labelledby is not None and len(labelledby) > 0
+
+
+@pytest.mark.django_db
+class TestVoucherAccessibility:
+ """Test accessibility of voucher input."""
+
+ def test_voucher_input_has_aria_labelledby(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_voucher,
+ widget_page
+ ):
+ """
+ Voucher input should reference the headline via aria-labelledby.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ voucher_input = page.locator('.pretix-widget-voucher-input')
+ headline = page.locator('.pretix-widget-voucher-headline')
+
+ # Headline should have an ID
+ headline_id = headline.get_attribute('id')
+ assert headline_id is not None
+
+ # Input should reference it
+ labelledby = voucher_input.get_attribute('aria-labelledby')
+ assert labelledby == headline_id
+
+
+@pytest.mark.django_db
+class TestVariationAccessibility:
+ """Test accessibility of variation toggles."""
+
+ def test_variations_toggle_has_aria_expanded(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_variations,
+ widget_page
+ ):
+ """
+ Variations toggle button should have aria-expanded
+ and aria-controls attributes.
+ """
+ item, _ = widget_item_with_variations
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ toggle_btn = item_elem.locator(
+ 'button[aria-expanded]')
+
+ # Should start collapsed
+ expect(toggle_btn).to_have_attribute('aria-expanded', 'false')
+
+ # Should reference the variations container
+ controls = toggle_btn.get_attribute('aria-controls')
+ assert controls is not None
+
+ # Click to expand
+ toggle_btn.click()
+ expect(toggle_btn).to_have_attribute('aria-expanded', 'true')
+
+
+@pytest.mark.django_db
+class TestCalendarAccessibility:
+ """Test accessibility of calendar view."""
+
+ def test_calendar_table_is_focusable(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event_series,
+ widget_page
+ ):
+ """
+ Calendar table should have tabindex="0" and aria-labelledby.
+ """
+ event, _ = widget_event_series
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ event.slug,
+ **{'list-type': 'calendar'}
+ )
+ widget_page.wait_for_widget_load()
+
+ table = page.locator('.pretix-widget-event-calendar-table')
+ expect(table).to_have_attribute('tabindex', '0')
+
+ # Should be labeled by the month heading
+ labelledby = table.get_attribute('aria-labelledby')
+ assert labelledby is not None
+
+ def test_calendar_day_headers_have_aria_labels(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event_series,
+ widget_page
+ ):
+ """
+ Calendar day-of-week headers should have full day names
+ as aria-labels (Mo -> Monday, Tu -> Tuesday, etc.).
+ """
+ event, _ = widget_event_series
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ event.slug,
+ **{'list-type': 'calendar'}
+ )
+ widget_page.wait_for_widget_load()
+
+ # Check first day header has aria-label
+ first_header = page.locator(
+ '.pretix-widget-event-calendar-table thead th').first
+ label = first_header.get_attribute('aria-label')
+ assert label is not None
+ # Should be a full day name like "Monday"
+ assert len(label) > 2
+
+
+@pytest.mark.django_db
+class TestKeyboardNavigation:
+ """Test keyboard navigation through the widget."""
+
+ def test_tab_reaches_interactive_elements(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Pressing Tab should cycle through interactive elements
+ (inputs, buttons) within the widget.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Tab through several elements
+ focused_tags = set()
+ for _ in range(10):
+ page.keyboard.press('Tab')
+ tag = page.evaluate('() => document.activeElement.tagName')
+ focused_tags.add(tag)
+
+ # Should have reached at least inputs and buttons
+ assert 'INPUT' in focused_tags or 'BUTTON' in focused_tags
diff --git a/src/tests/e2e/test_widget_availability.py b/src/tests/e2e/test_widget_availability.py
new file mode 100644
index 0000000000..ca97d285c2
--- /dev/null
+++ b/src/tests/e2e/test_widget_availability.py
@@ -0,0 +1,210 @@
+"""
+E2E Tests for Availability States
+
+Tests that verify:
+- Sold out items show "Sold out" message
+- Low stock items show "currently available: N"
+- Require-voucher items show voucher message
+- Not-yet-available items show "Not yet available"
+- Available items show quantity selector
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestSoldOutState:
+ """Test sold out availability display."""
+
+ def test_sold_out_shows_message(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_sold_out,
+ widget_page
+ ):
+ """Sold out item should show 'Sold out' text."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_sold_out.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should show "Sold out" in the availability area
+ avail = item_elem.locator('.pretix-widget-availability-gone')
+ expect(avail).to_be_visible()
+
+ def test_sold_out_has_no_quantity_selector(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_sold_out,
+ widget_page
+ ):
+ """Sold out item should not show any input controls."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_sold_out.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should not have any input controls
+ assert item_elem.locator('input').count() == 0
+
+
+@pytest.mark.django_db
+class TestQuotaLeftDisplay:
+ """Test quota-left indicator for low stock items."""
+
+ def test_low_stock_shows_currently_available(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_low_stock,
+ widget_page
+ ):
+ """
+ Item with low stock and show_quota_left should display
+ the number of remaining tickets.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_low_stock.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should show quota left text containing "3"
+ # The widget uses "currently available: 3" format
+ expect(item_elem).to_contain_text('3')
+
+ def test_low_stock_still_has_quantity_selector(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_low_stock,
+ widget_page
+ ):
+ """Low stock items should still be purchasable."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_low_stock.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should have quantity input
+ expect(item_elem.locator('input')).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestRequireVoucherState:
+ """Test require-voucher unavailability message."""
+
+ def test_require_voucher_shows_message(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_require_voucher,
+ widget_page
+ ):
+ """Item requiring voucher should show 'Only available with a voucher'."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_require_voucher.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should show unavailability message with voucher link
+ unavail = item_elem.locator('.pretix-widget-availability-unavailable')
+ expect(unavail).to_be_visible()
+ expect(unavail).to_contain_text('voucher')
+
+ def test_require_voucher_has_link_to_voucher_input(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_require_voucher,
+ widget_voucher,
+ widget_page
+ ):
+ """Voucher-required message should link to the voucher input field."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_require_voucher.name}")')
+ unavail = item_elem.locator('.pretix-widget-availability-unavailable')
+
+ # Should have a link (to jump to voucher input)
+ link = unavail.locator('a')
+ expect(link).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestNotYetAvailable:
+ """Test not-yet-available state."""
+
+ def test_future_item_shows_not_yet_available(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_not_yet_available,
+ widget_page
+ ):
+ """Item with future available_from should show 'Not yet available'."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_not_yet_available.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should show unavailability message
+ unavail = item_elem.locator('.pretix-widget-availability-unavailable')
+ expect(unavail).to_be_visible()
+
+ def test_future_item_has_no_quantity_selector(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_not_yet_available,
+ widget_page
+ ):
+ """Not-yet-available items should not have quantity controls."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_not_yet_available.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should not have any input controls
+ assert item_elem.locator('input').count() == 0
diff --git a/src/tests/e2e/test_widget_cart.py b/src/tests/e2e/test_widget_cart.py
new file mode 100644
index 0000000000..145ed3afc5
--- /dev/null
+++ b/src/tests/e2e/test_widget_cart.py
@@ -0,0 +1,302 @@
+"""
+E2E Tests for Cart Management & Checkout Flow
+
+Tests that verify:
+- Adding items to cart opens iframe checkout
+- Empty cart does not open checkout
+- Cart persistence via cookies
+- Resume checkout after page reload
+- Multiple item selection
+- Mixed input types (checkbox + quantity)
+"""
+import pytest
+from playwright.sync_api import Page, expect, BrowserContext
+
+
+@pytest.mark.django_db
+class TestCartBasics:
+ """Test basic cart functionality."""
+
+ def test_add_to_cart_opens_iframe_checkout(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """Selecting items and clicking Buy should open iframe checkout."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ widget_page.select_item_quantity(widget_items[0].name, 2)
+ widget_page.click_buy_button()
+
+ # Iframe checkout should open
+ widget_page.wait_for_iframe_checkout()
+
+ iframe_elem = page.locator('iframe[name^="pretix-widget-"]')
+ src = iframe_elem.get_attribute('src')
+ assert 'iframe=1' in src
+ assert 'take_cart_id' in src
+
+ # page.pause()
+
+ def test_empty_cart_does_not_open_checkout(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """Clicking Buy without selecting items should not open checkout."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Don't select any items, just click buy
+ widget_page.click_buy_button()
+ page.wait_for_timeout(1000)
+
+ # No iframe should have opened
+ expect(page.locator('.pretix-widget-frame-shown')).not_to_be_visible()
+
+ # Widget should still be visible (didn't navigate away)
+ expect(page.locator('.pretix-widget')).to_be_visible()
+
+ # Items should still be there
+ expect(page.locator(
+ f'.pretix-widget-item:has-text("{widget_items[0].name}")'
+ )).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestCartPersistence:
+ """Test cart persistence across page reloads."""
+
+ def test_cart_cookie_set_after_checkout(
+ self,
+ page: Page,
+ context: BrowserContext,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """Adding items to cart should create a pretix_widget cookie."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ widget_page.select_item_quantity(widget_items[0].name, 1)
+ widget_page.click_buy_button()
+
+ # Wait for iframe checkout to open (cookie is set during cart creation)
+ widget_page.wait_for_iframe_checkout()
+ page.wait_for_timeout(2000)
+
+ cookies = context.cookies()
+ widget_cookies = [c for c in cookies if c['name'].startswith('pretix_widget_')]
+ assert len(widget_cookies) > 0, (
+ f"Expected pretix_widget cookie after checkout, got: {[c['name'] for c in cookies]}"
+ )
+
+ def test_resume_checkout_after_reload(
+ self,
+ page: Page,
+ context: BrowserContext,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """After creating a cart and reloading, widget should show resume option."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Create a real cart by adding items and opening checkout
+ widget_page.select_item_quantity(widget_items[0].name, 1)
+ widget_page.click_buy_button()
+ widget_page.wait_for_iframe_checkout()
+
+ # Wait for cart cookie to be set (set in buy_callback when cart is created)
+ page.wait_for_function(
+ "() => document.cookie.includes('pretix_widget_')",
+ timeout=10000
+ )
+
+ # Close iframe and reload
+ widget_page.close_iframe()
+ page.reload()
+ widget_page.wait_for_widget_load()
+
+ # Should show "Resume checkout" button (class: pretix-widget-resume-button)
+ resume_btn = page.locator('.pretix-widget-resume-button')
+ expect(resume_btn).to_be_visible(timeout=5000)
+
+
+@pytest.mark.django_db
+class TestIframeCheckout:
+ """Test iframe checkout flow (enabled via skip-ssl-check + SITE_URL fix)."""
+
+ def test_iframe_checkout_opens(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """With skip-ssl-check, checkout should open in iframe on HTTP."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ widget_page.select_item_quantity(widget_items[0].name, 1)
+ widget_page.click_buy_button()
+
+ widget_page.wait_for_iframe_checkout()
+
+ iframe_elem = page.locator('iframe[name^="pretix-widget-"]')
+ src = iframe_elem.get_attribute('src')
+ assert 'iframe=1' in src
+ assert widget_organizer.slug in src
+
+ def test_close_iframe_button(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """User should be able to close checkout iframe."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ widget_page.select_item_quantity(widget_items[0].name, 1)
+ widget_page.click_buy_button()
+
+ widget_page.wait_for_iframe_checkout()
+ page.wait_for_timeout(3000)
+
+ widget_page.close_iframe()
+
+ expect(page.locator('.pretix-widget-frame-shown')).not_to_be_visible()
+
+ def test_iframe_checkout_has_take_cart_id(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """Iframe checkout URL should include take_cart_id parameter."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ widget_page.select_item_quantity(widget_items[0].name, 2)
+ widget_page.click_buy_button()
+
+ widget_page.wait_for_iframe_checkout()
+
+ iframe_elem = page.locator('iframe[name^="pretix-widget-"]')
+ src = iframe_elem.get_attribute('src')
+ assert 'take_cart_id' in src
+
+ def test_overlay_visible_during_checkout(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """Iframe checkout overlay container should be visible during checkout."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ widget_page.select_item_quantity(widget_items[0].name, 1)
+ widget_page.click_buy_button()
+
+ widget_page.wait_for_iframe_checkout()
+
+ overlay = page.locator('.pretix-widget-frame-holder')
+ expect(overlay).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestMultipleItemSelection:
+ """Test selecting and submitting multiple items."""
+
+ def test_select_multiple_different_items(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """Selecting multiple items should open checkout with all of them."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Select multiple items
+ widget_page.select_item_quantity(widget_items[0].name, 2)
+ widget_page.select_item_quantity(widget_items[1].name, 1)
+
+ widget_page.click_buy_button()
+
+ # Iframe should open with both items in the cart
+ widget_page.wait_for_iframe_checkout()
+
+ iframe_elem = page.locator('iframe[name^="pretix-widget-"]')
+ src = iframe_elem.get_attribute('src')
+ assert 'take_cart_id' in src
+
+ def test_mixed_checkbox_and_quantity_items(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_item_single_select,
+ widget_page
+ ):
+ """Selecting both checkbox and quantity items should open checkout."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Select quantity item
+ widget_page.select_item_quantity(widget_items[0].name, 3)
+
+ # Select checkbox item
+ widget_page.select_item_quantity(widget_item_single_select.name, 1)
+
+ widget_page.click_buy_button()
+
+ # Iframe should open
+ widget_page.wait_for_iframe_checkout()
+
+ iframe_elem = page.locator('iframe[name^="pretix-widget-"]')
+ src = iframe_elem.get_attribute('src')
+ assert 'take_cart_id' in src
diff --git a/src/tests/e2e/test_widget_categories.py b/src/tests/e2e/test_widget_categories.py
new file mode 100644
index 0000000000..11593dd7b1
--- /dev/null
+++ b/src/tests/e2e/test_widget_categories.py
@@ -0,0 +1,107 @@
+"""
+E2E Tests for Categories & Organization
+
+Tests that verify:
+- Category headers display correctly
+- Category descriptions render
+- Items are grouped under their respective categories
+- Category sort order is maintained
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestCategoryDisplay:
+ """Test category header and description rendering."""
+
+ def test_category_headers_display(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Category names should be shown as h3 headers.
+
+ The widget_items fixture creates a 'Tickets' category.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Category header should be visible
+ category_header = page.locator(
+ '.pretix-widget-category-name:text-is("Tickets")')
+ expect(category_header).to_be_visible()
+
+ def test_category_description_renders(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items_with_category_description,
+ widget_page
+ ):
+ """
+ Category descriptions should be displayed below category name.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Category description should be visible
+ desc = page.locator('.pretix-widget-category-description')
+ expect(desc.first).to_be_visible()
+ expect(desc.first).to_contain_text('Early bird tickets available')
+
+ def test_items_grouped_by_category(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items_multiple_categories,
+ widget_page
+ ):
+ """
+ Items should be grouped under respective categories
+ and maintain category sort order.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Both categories should be visible
+ expect(page.locator(
+ '.pretix-widget-category-name:text-is("Music")'
+ )).to_be_visible()
+ expect(page.locator(
+ '.pretix-widget-category-name:text-is("Food & Drink")'
+ )).to_be_visible()
+
+ # Concert Ticket should be under "Music" category
+ music_cat = page.locator(
+ '.pretix-widget-category:has(.pretix-widget-category-name'
+ ':text-is("Music"))')
+ expect(music_cat.locator(
+ '.pretix-widget-item:has-text("Concert Ticket")'
+ )).to_be_visible()
+
+ # Food Pass should be under "Food & Drink" category
+ food_cat = page.locator(
+ '.pretix-widget-category:has(.pretix-widget-category-name'
+ ':text-is("Food & Drink"))')
+ expect(food_cat.locator(
+ '.pretix-widget-item:has-text("Food Pass")'
+ )).to_be_visible()
+
+ # Verify ordering: Music (position=0) should come before
+ # Food & Drink (position=1)
+ categories = page.locator('.pretix-widget-category-name')
+ first_cat = categories.nth(0)
+ expect(first_cat).to_have_text('Music')
diff --git a/src/tests/e2e/test_widget_config.py b/src/tests/e2e/test_widget_config.py
new file mode 100644
index 0000000000..2b2b18a64e
--- /dev/null
+++ b/src/tests/e2e/test_widget_config.py
@@ -0,0 +1,187 @@
+"""
+E2E Tests for Widget Configuration Attributes
+
+Tests that verify:
+- items attribute filters to specific products
+- categories attribute filters by category
+- disable-vouchers hides voucher input
+- disable-iframe forces new tab checkout
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestItemsFilter:
+ """Test items attribute for filtering products."""
+
+ def test_items_attribute_shows_only_specified_items(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ When items="" is set, only that item should be shown.
+ """
+ # Get the first item's ID
+ target_item = widget_items[0]
+ other_item = widget_items[1]
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ widget_event.slug,
+ items=str(target_item.pk)
+ )
+ widget_page.wait_for_widget_load()
+
+ # Target item should be visible
+ expect(page.locator(
+ f'.pretix-widget-item:has-text("{target_item.name}")'
+ )).to_be_visible()
+
+ # Other item should NOT be visible
+ expect(page.locator(
+ f'.pretix-widget-item:has-text("{other_item.name}")'
+ )).to_have_count(0)
+
+ def test_items_attribute_with_multiple_ids(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ When items=",", both items should be shown.
+ """
+ ids = ','.join(str(item.pk) for item in widget_items)
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ widget_event.slug,
+ items=ids
+ )
+ widget_page.wait_for_widget_load()
+
+ # Both items should be visible
+ for item in widget_items:
+ expect(page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")'
+ )).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestCategoriesFilter:
+ """Test categories attribute for filtering by category."""
+
+ def test_categories_attribute_shows_only_specified_category(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items_multiple_categories,
+ widget_page
+ ):
+ """
+ When categories="" is set, only items from that category
+ should be shown.
+ """
+ items = widget_items_multiple_categories
+ # Get the category of the first item (Music)
+ target_category = items[0].category
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ widget_event.slug,
+ categories=str(target_category.pk)
+ )
+ widget_page.wait_for_widget_load()
+
+ # Music item should be visible
+ expect(page.locator(
+ f'.pretix-widget-item:has-text("{items[0].name}")'
+ )).to_be_visible()
+
+ # Food item should NOT be visible
+ expect(page.locator(
+ f'.pretix-widget-item:has-text("{items[1].name}")'
+ )).to_have_count(0)
+
+
+@pytest.mark.django_db
+class TestDisableVouchers:
+ """Test disable-vouchers attribute."""
+
+ def test_disable_vouchers_hides_voucher_input(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_voucher,
+ widget_page
+ ):
+ """
+ When disable-vouchers is set, voucher input should be hidden
+ even when vouchers exist.
+ """
+ # Navigate with disable-vouchers attribute
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ widget_event.slug,
+ **{'disable-vouchers': ''}
+ )
+ widget_page.wait_for_widget_load()
+
+ # Voucher section should NOT be visible
+ voucher_section = page.locator('.pretix-widget-voucher')
+ expect(voucher_section).to_have_count(0)
+
+
+@pytest.mark.django_db
+class TestDisableIframe:
+ """Test disable-iframe attribute."""
+
+ def test_disable_iframe_opens_new_tab(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ When disable-iframe is set, checkout should open in a new tab
+ instead of an iframe overlay.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ widget_event.slug,
+ **{'disable-iframe': ''}
+ )
+ widget_page.wait_for_widget_load()
+
+ # Select quantity for an item
+ widget_page.select_item_quantity('General Admission', 1)
+
+ # Clicking buy should open a new tab (popup), not an iframe
+ with page.expect_popup() as popup_info:
+ widget_page.click_buy_button()
+
+ popup = popup_info.value
+ # New tab should navigate to checkout URL
+ assert widget_organizer.slug in popup.url
+ popup.close()
diff --git a/src/tests/e2e/test_widget_display_modes.py b/src/tests/e2e/test_widget_display_modes.py
new file mode 100644
index 0000000000..0b48505689
--- /dev/null
+++ b/src/tests/e2e/test_widget_display_modes.py
@@ -0,0 +1,317 @@
+"""
+E2E Tests for Widget Display Modes
+
+Tests that verify:
+- Default widget mode shows full ticket shop
+- Calendar view for event series
+- List view for event series
+- Button mode opens checkout directly
+- Calendar navigation (next/prev month)
+- Clicking a date in calendar navigates to event
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestWidgetMode:
+ """Test default widget mode (full ticket shop)."""
+
+ def test_widget_mode_shows_items_and_buy_button(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Default widget mode should show full product listing
+ with categories, items, and a buy button.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Should show items
+ for item in widget_items:
+ expect(page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")'
+ )).to_be_visible()
+
+ # Should show buy/add-to-cart button
+ expect(page.locator(
+ 'button:has-text("Add to cart"), button:has-text("Buy")'
+ ).first).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestCalendarView:
+ """Test calendar display mode for event series."""
+
+ def test_calendar_view_displays_month_grid(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event_series,
+ widget_page
+ ):
+ """
+ Calendar view should show a monthly grid with event dates.
+ """
+ event, subevents = widget_event_series
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ event.slug,
+ **{'list-type': 'calendar'}
+ )
+ widget_page.wait_for_widget_load()
+
+ # Should show calendar table
+ expect(page.locator(
+ '.pretix-widget-event-calendar-table'
+ )).to_be_visible()
+
+ # Should have day cells with events
+ event_cells = page.locator('.pretix-widget-has-events')
+ expect(event_cells.first).to_be_visible()
+
+ def test_calendar_view_navigation_next_month(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event_series,
+ widget_page
+ ):
+ """
+ Clicking next month button should navigate to the next month.
+ """
+ event, subevents = widget_event_series
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ event.slug,
+ **{'list-type': 'calendar'}
+ )
+ widget_page.wait_for_widget_load()
+
+ # Get current month heading text
+ header = page.locator('.pretix-widget-event-calendar-head')
+ initial_text = header.inner_text()
+
+ # Click next month button
+ next_btn = page.locator(
+ '.pretix-widget-event-calendar-head a').last
+ next_btn.click()
+
+ # Wait for calendar to update
+ page.wait_for_timeout(1000)
+
+ # Month heading should change
+ updated_text = header.inner_text()
+ assert updated_text != initial_text
+
+ def test_calendar_event_links_are_clickable(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event_series,
+ widget_page
+ ):
+ """
+ Calendar event entries should be clickable links that show
+ event name, time, and availability status.
+
+ Note: Full subevent navigation requires domain configuration
+ (target_url resolves to configured domain, not live_server).
+ We verify the links exist and have correct structure.
+ """
+ event, _ = widget_event_series
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ event.slug,
+ **{'list-type': 'calendar'}
+ )
+ widget_page.wait_for_widget_load()
+
+ # Event links should exist and show event info
+ event_link = page.locator(
+ '.pretix-widget-event-calendar-event').first
+ expect(event_link).to_be_visible()
+
+ # Should show event name
+ expect(event_link.locator(
+ '.pretix-widget-event-calendar-event-name'
+ )).to_be_visible()
+
+ # Should show time range
+ expect(event_link.locator(
+ '.pretix-widget-event-calendar-event-date'
+ )).to_be_visible()
+
+ # Should show availability ("Buy now" for available events)
+ expect(event_link.locator(
+ '.pretix-widget-event-calendar-event-availability'
+ )).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestListView:
+ """Test list display mode for event series."""
+
+ def test_list_view_displays_events(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event_series,
+ widget_page
+ ):
+ """
+ List view should display events as a linear list.
+ """
+ event, subevents = widget_event_series
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ event.slug,
+ **{'list-type': 'list'}
+ )
+ widget_page.wait_for_widget_load()
+
+ # Should show event list entries
+ entries = page.locator('.pretix-widget-event-list-entry')
+ expect(entries.first).to_be_visible()
+
+ # Should have multiple entries
+ assert entries.count() > 0
+
+ def test_list_view_shows_event_names(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event_series,
+ widget_page
+ ):
+ """
+ List view should show subevent names.
+ """
+ event, subevents = widget_event_series
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ event.slug,
+ **{'list-type': 'list'}
+ )
+ widget_page.wait_for_widget_load()
+
+ # At least the first subevent name should appear
+ expect(page.locator(
+ f':text-is("{subevents[0].name}")'
+ )).to_be_visible()
+
+ def test_list_view_entries_show_availability(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event_series,
+ widget_page
+ ):
+ """
+ List view entries should show availability status.
+
+ Note: Full subevent navigation requires domain configuration
+ (target_url resolves to configured domain, not live_server).
+ We verify the entries have correct structure.
+ """
+ event, _ = widget_event_series
+
+ widget_page.goto_widget_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ event.slug,
+ **{'list-type': 'list'}
+ )
+ widget_page.wait_for_widget_load()
+
+ # Each entry should show availability info
+ first_entry = page.locator(
+ '.pretix-widget-event-list-entry').first
+ expect(first_entry).to_be_visible()
+
+ # Should show availability indicator (green = available)
+ availability = first_entry.locator(
+ '.pretix-widget-event-list-entry-availability')
+ expect(availability).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestButtonMode:
+ """Test button display mode."""
+
+ def test_button_mode_shows_button(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Button mode should show a simple button.
+ """
+ widget_page.goto_button_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ widget_event.slug
+ )
+
+ # Wait for script to load and initialize
+ page.wait_for_timeout(2000)
+
+ # Should show the button
+ button = page.locator('.pretix-button')
+ expect(button).to_be_visible()
+ expect(button).to_have_text('Buy tickets!')
+
+ def test_button_click_opens_checkout(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Clicking the button should open checkout (new tab on HTTP).
+ """
+ widget_page.goto_button_test_page(
+ live_server_url,
+ widget_organizer.slug,
+ widget_event.slug
+ )
+
+ page.wait_for_timeout(2000)
+
+ # Click button - on HTTP it opens in new tab
+ with page.expect_popup() as popup_info:
+ page.locator('.pretix-button').click()
+
+ popup = popup_info.value
+ assert widget_organizer.slug in popup.url
+ popup.close()
diff --git a/src/tests/e2e/test_widget_edge_cases.py b/src/tests/e2e/test_widget_edge_cases.py
new file mode 100644
index 0000000000..74a35d3573
--- /dev/null
+++ b/src/tests/e2e/test_widget_edge_cases.py
@@ -0,0 +1,131 @@
+"""
+E2E Tests for Edge Cases
+
+Tests that verify:
+- Empty event (no items) displays gracefully
+- Item with min_per_order enforces minimum
+- Zero quantity submission shows warning
+- Widget handles special characters in names
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestEmptyStates:
+ """Test widget behavior with empty or minimal data."""
+
+ def test_event_with_no_items_shows_empty(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_page
+ ):
+ """
+ Event with no items should still load without errors.
+ Should show the widget container but no item rows.
+ """
+ # Navigate without creating any items
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Widget should still be present
+ expect(page.locator('.pretix-widget')).to_be_visible()
+
+ # No items should be shown
+ items = page.locator('.pretix-widget-item')
+ assert items.count() == 0
+
+ def test_zero_quantity_stays_on_widget(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Submitting with zero quantity should not navigate away.
+ The widget should remain visible without opening checkout.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Don't select any items, just click buy
+ widget_page.click_buy_button()
+ page.wait_for_timeout(1000)
+
+ # Widget should still be on the same page (no checkout opened)
+ expect(page.locator('.pretix-widget')).to_be_visible()
+
+ # Items should still be visible
+ expect(page.locator(
+ f'.pretix-widget-item:has-text("{widget_items[0].name}")'
+ )).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestMinPerOrder:
+ """Test minimum order quantity enforcement."""
+
+ def test_item_with_min_per_order_shows_message(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_min_order,
+ widget_page
+ ):
+ """
+ Items with min_per_order should display a text message
+ indicating the minimum quantity (e.g. "minimum amount to order: 2").
+ """
+ item = widget_item_min_order
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should show minimum order message containing "2"
+ meta = item_elem.locator('.pretix-widget-item-meta')
+ expect(meta).to_be_visible()
+ expect(meta).to_contain_text('2')
+
+
+@pytest.mark.django_db
+class TestSpecialCharacters:
+ """Test widget handles special characters correctly."""
+
+ def test_item_name_with_special_characters(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_special_chars,
+ widget_page
+ ):
+ """
+ Items with special characters (umlauts, ampersands, etc.)
+ should display correctly.
+ """
+ item = widget_item_special_chars
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Item with special characters should be visible
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
diff --git a/src/tests/e2e/test_widget_embedding.py b/src/tests/e2e/test_widget_embedding.py
new file mode 100644
index 0000000000..edd015c4a2
--- /dev/null
+++ b/src/tests/e2e/test_widget_embedding.py
@@ -0,0 +1,194 @@
+"""
+E2E Tests for Widget Embedding & Initialization
+
+Tests that verify the pretix widget loads correctly, initializes properly,
+and displays basic event information.
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestWidgetEmbedding:
+ """Test basic widget embedding and initialization."""
+
+ def test_widget_loads_successfully(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget should load and display event information.
+
+ Verifies that the widget loads on the page and shows:
+ - Event name
+ - All configured items
+ """
+ # Navigate to test page with widget embedded
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug
+ )
+ widget_page.wait_for_widget_load()
+
+ # Verify widget container exists with event aria-label
+ widget = page.locator('.pretix-widget-wrapper')
+ expect(widget).to_have_attribute('aria-label', widget_event.name)
+
+ # Verify items are listed
+ for item in widget_items:
+ expect(page.locator(f'text="{item.name}"')).to_be_visible()
+
+ def test_widget_displays_loading_state(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_page
+ ):
+ """
+ Widget should show loading spinner during initial load.
+
+ The loading spinner should eventually disappear when data is loaded.
+ """
+ # Navigate to widget test page
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug
+ )
+
+ # Wait for widget element
+ page.wait_for_selector('.pretix-widget', timeout=10000)
+
+ # Loading spinner should eventually be hidden
+ loading = page.locator('.pretix-widget-loading')
+ expect(loading).to_be_hidden(timeout=10000)
+
+ def test_widget_handles_invalid_event(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_page
+ ):
+ """Widget should display error message for invalid event."""
+ widget_page.goto_widget_test_page(
+ live_server_url, 'invalid-org', 'invalid-event'
+ )
+
+ # Should show widget container
+ page.wait_for_selector('.pretix-widget', timeout=10000)
+
+ # Should show error message
+ error_msg = page.locator('.pretix-widget-error-message')
+ expect(error_msg).to_be_visible(timeout=10000)
+ expect(error_msg).to_contain_text('could not be loaded')
+
+ def test_widget_shows_item_descriptions(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget should display item descriptions.
+
+ Item descriptions should be visible for items that have them.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug
+ )
+ widget_page.wait_for_widget_load()
+
+ # Check that descriptions are shown
+ for item in widget_items:
+ if item.description:
+ expect(
+ page.locator(f'text="{item.description}"')
+ ).to_be_visible()
+
+ def test_widget_shows_item_prices(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget should display item prices correctly.
+
+ Prices should be formatted with currency and decimal places.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug
+ )
+ widget_page.wait_for_widget_load()
+
+ # Verify prices are shown (with currency)
+ # Each item should have its price displayed
+ for item in widget_items:
+ # Find the item container first, then check price within it
+ item_container = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")'
+ )
+ expect(item_container).to_be_visible()
+
+ # Check price is present (formatted as "USD XX.XX")
+ price_text = f"{float(item.default_price):.2f}"
+ price_box = item_container.locator('.pretix-widget-pricebox')
+ expect(price_box).to_contain_text(price_text)
+
+
+@pytest.mark.django_db
+class TestWidgetEventInfo:
+ """Test event information display in widget."""
+
+ def test_widget_displays_event_date(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_page
+ ):
+ """
+ Widget should show event date and time.
+
+ Note: The old widget.js implementation may not display event
+ date by default. This test verifies the widget loads without
+ checking for specific date display.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug
+ )
+ widget_page.wait_for_widget_load()
+
+ # Widget should be present and functional
+ # (Event date display varies by configuration)
+ widget = page.locator('.pretix-widget')
+ expect(widget).to_be_visible()
+
+ def test_widget_hides_event_info_when_configured(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_page
+ ):
+ """
+ Widget should hide event info when display-event-info="false".
+
+ This test would require creating a widget embed page with the attribute.
+ Currently just a placeholder for future implementation.
+ """
+ # TODO: This requires creating a custom HTML page with widget embed
+ # For now, skip this test
+ pytest.skip("Requires custom widget embed page with attributes")
diff --git a/src/tests/e2e/test_widget_errors.py b/src/tests/e2e/test_widget_errors.py
new file mode 100644
index 0000000000..96ab6f72c3
--- /dev/null
+++ b/src/tests/e2e/test_widget_errors.py
@@ -0,0 +1,120 @@
+"""
+E2E Tests for Error Handling
+
+Tests that verify:
+- Error message on invalid event
+- Error message shows "Open ticket shop" link
+- Sold out items show unavailable state
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestErrorDisplay:
+ """Test error messages and states."""
+
+ def test_invalid_event_shows_error_message(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_page
+ ):
+ """
+ Loading a non-existent event should show an error message.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, 'nonexistent-event')
+ widget_page.wait_for_widget_load()
+
+ # Should show error message
+ expect(page.locator(
+ '.pretix-widget-error-message'
+ )).to_be_visible()
+
+ def test_error_shows_open_in_new_tab_link(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_page
+ ):
+ """
+ Error state should include a link to open the ticket shop
+ in a new tab as a fallback.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, 'nonexistent-event')
+ widget_page.wait_for_widget_load()
+
+ # Should show fallback action link
+ action_link = page.locator('.pretix-widget-error-action a')
+ expect(action_link).to_be_visible()
+
+ # Link should open in new tab
+ expect(action_link).to_have_attribute('target', '_blank')
+
+
+@pytest.mark.django_db
+class TestSoldOutState:
+ """Test sold out item display."""
+
+ def test_sold_out_item_shows_unavailable(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_sold_out,
+ widget_page
+ ):
+ """
+ Items with zero quota should show as unavailable/sold out.
+ """
+ item = widget_item_sold_out
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should not have a quantity input or checkbox (sold out)
+ input_count = item_elem.locator(
+ 'input[type="number"], input[type="checkbox"]').count()
+ assert input_count == 0
+
+ def test_sold_out_item_shows_status_text(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_sold_out,
+ widget_page
+ ):
+ """
+ Sold out items should show a status message like
+ "Sold out" or "Currently unavailable".
+ """
+ item = widget_item_sold_out
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+
+ # Should show some unavailability text
+ avail_col = item_elem.locator(
+ '.pretix-widget-item-availability-col')
+ expect(avail_col).to_be_visible()
+ # The text could be "Sold out", "Currently unavailable", etc.
+ avail_text = avail_col.inner_text()
+ assert len(avail_text.strip()) > 0
diff --git a/src/tests/e2e/test_widget_lightbox.py b/src/tests/e2e/test_widget_lightbox.py
new file mode 100644
index 0000000000..66fb8f97ca
--- /dev/null
+++ b/src/tests/e2e/test_widget_lightbox.py
@@ -0,0 +1,208 @@
+"""
+E2E Tests for Item Images & Lightbox
+
+Tests that verify:
+- Item with picture shows thumbnail
+- Clicking thumbnail opens lightbox overlay
+- Lightbox close button works
+- Lightbox has correct ARIA structure
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestItemPicture:
+ """Test item picture thumbnail display."""
+
+ def test_item_with_picture_shows_thumbnail(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_picture,
+ widget_page
+ ):
+ """Item with picture should display a thumbnail image."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should have picture element
+ picture = item_elem.locator('.pretix-widget-item-picture')
+ expect(picture).to_be_visible()
+
+ def test_item_with_picture_has_alt_text(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_picture,
+ widget_page
+ ):
+ """Item picture should have alt text."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")')
+
+ img = item_elem.locator('.pretix-widget-item-picture')
+ alt = img.get_attribute('alt')
+ assert alt is not None and len(alt) > 0
+
+ def test_item_with_picture_has_link(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_picture,
+ widget_page
+ ):
+ """Picture should be wrapped in a clickable link."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")')
+
+ link = item_elem.locator('.pretix-widget-item-picture-link')
+ expect(link).to_be_visible()
+ href = link.get_attribute('href')
+ assert href is not None and len(href) > 0
+
+ def test_item_has_picture_class(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_picture,
+ widget_page
+ ):
+ """Item row should have pretix-widget-item-with-picture class."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item-with-picture:has-text("{widget_item_with_picture.name}")')
+ expect(item_elem).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestLightbox:
+ """Test lightbox overlay for item pictures."""
+
+ def test_click_picture_opens_lightbox(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_picture,
+ widget_page
+ ):
+ """Clicking item picture should open lightbox overlay."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")')
+
+ # Click the picture link
+ link = item_elem.locator('.pretix-widget-item-picture-link')
+ link.click()
+
+ # Lightbox should appear
+ lightbox = page.locator('.pretix-widget-lightbox-shown')
+ expect(lightbox).to_be_visible()
+
+ def test_lightbox_shows_fullsize_image(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_picture,
+ widget_page
+ ):
+ """Lightbox should display fullsize image."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")')
+ item_elem.locator('.pretix-widget-item-picture-link').click()
+
+ # Wait for lightbox
+ page.wait_for_timeout(1000)
+
+ # Should have an image inside the lightbox
+ lightbox_img = page.locator('.pretix-widget-lightbox-image img')
+ expect(lightbox_img).to_be_visible()
+
+ def test_lightbox_close_button(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_picture,
+ widget_page
+ ):
+ """Lightbox close button should close the overlay."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")')
+ item_elem.locator('.pretix-widget-item-picture-link').click()
+
+ # Wait for lightbox to appear
+ lightbox = page.locator('.pretix-widget-lightbox-shown')
+ expect(lightbox).to_be_visible()
+
+ # Click close button
+ close_btn = page.locator('.pretix-widget-lightbox-close button')
+ close_btn.click()
+
+ # Lightbox should close
+ page.wait_for_timeout(500)
+ expect(page.locator('.pretix-widget-lightbox-shown')).not_to_be_visible()
+
+ def test_lightbox_has_alertdialog_role(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_picture,
+ widget_page
+ ):
+ """Lightbox dialog should have role='alertdialog'."""
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")')
+ item_elem.locator('.pretix-widget-item-picture-link').click()
+
+ page.wait_for_timeout(1000)
+
+ dialog = page.locator('.pretix-widget-lightbox-holder')
+ role = dialog.get_attribute('role')
+ assert role == 'alertdialog'
diff --git a/src/tests/e2e/test_widget_loading.py b/src/tests/e2e/test_widget_loading.py
new file mode 100644
index 0000000000..5114c63eff
--- /dev/null
+++ b/src/tests/e2e/test_widget_loading.py
@@ -0,0 +1,111 @@
+"""
+E2E Tests for Loading States & Performance
+
+Tests that verify:
+- Loading spinner appears during widget initialization
+- Loading spinner disappears after content loads
+- Widget loads within acceptable time
+- No JavaScript errors during widget initialization
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestLoadingStates:
+ """Test loading states and transitions."""
+
+ def test_loading_spinner_disappears_after_load(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Loading spinner should be hidden once widget content loads.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Loading spinner should be hidden (display:none)
+ loading = page.locator('.pretix-widget-loading')
+ expect(loading).to_be_hidden()
+
+ def test_widget_loads_within_timeout(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget should fully load within 15 seconds.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+
+ # Widget should appear within 15s
+ page.wait_for_selector('.pretix-widget', timeout=15000)
+
+ # Items should be visible within 15s total
+ expect(page.locator(
+ f'.pretix-widget-item:has-text("{widget_items[0].name}")'
+ )).to_be_visible(timeout=15000)
+
+ def test_no_javascript_errors_on_load(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget should load without any JavaScript console errors.
+ """
+ errors = []
+ page.on('pageerror', lambda err: errors.append(str(err)))
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # No JS errors should have occurred
+ assert len(errors) == 0, f"JavaScript errors: {errors}"
+
+
+@pytest.mark.django_db
+class TestWidgetReload:
+ """Test widget behavior on page interactions."""
+
+ def test_widget_css_loads_correctly(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget CSS should load and apply styles.
+ No SCSS syntax should leak into rendered styles.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Check that widget has actual styled dimensions
+ # (not zero-height which would indicate CSS failure)
+ widget = page.locator('.pretix-widget')
+ box = widget.bounding_box()
+ assert box is not None
+ assert box['height'] > 50 # Widget should have meaningful height
+ assert box['width'] > 100 # Widget should have meaningful width
diff --git a/src/tests/e2e/test_widget_pricing.py b/src/tests/e2e/test_widget_pricing.py
new file mode 100644
index 0000000000..a3d64aaf76
--- /dev/null
+++ b/src/tests/e2e/test_widget_pricing.py
@@ -0,0 +1,257 @@
+"""
+E2E Tests for Pricing & Tax Display
+
+Tests that verify:
+- Net vs gross price display
+- Tax information lines
+- Mixed tax rates
+- Original price strikethrough for discounts
+- Free items display
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestPriceDisplay:
+ """Test price formatting and display."""
+
+ def test_price_displays_with_currency(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Item prices should display with currency code.
+
+ Currency format should match event settings (USD).
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Check that prices are displayed with USD currency code
+ # General Admission is USD 50.00
+ # USD is in a separate span, so check for both parts
+ # Use .first since there are multiple items with USD
+ expect(page.locator('text=/USD/').first).to_be_visible()
+ expect(page.locator('text=/50\\.00/').first).to_be_visible()
+
+ def test_free_items_display_free_text(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_free,
+ widget_page
+ ):
+ """
+ Items with price 0.00 should display "FREE" instead of $0.00.
+
+ Makes free items more obvious to users.
+ """
+ item = widget_item_free
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Should show "FREE" text
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should contain "FREE" or "free" text (case insensitive)
+ expect(item_elem.locator('text=/free/i')).to_be_visible()
+
+ def test_price_includes_decimals(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_decimals,
+ widget_page
+ ):
+ """
+ Prices should display with proper decimal formatting.
+
+ USD should show 2 decimal places (e.g., $25.00 not $25).
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Should show price with .50
+ expect(page.locator('text=/USD.*12\\.50/')).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestTaxDisplay:
+ """Test tax information display."""
+
+ def test_tax_rate_displayed_for_taxed_items(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_tax,
+ widget_page
+ ):
+ """
+ Items with tax should show tax rate information.
+
+ Should display "incl. X% VAT" or similar.
+ """
+ item = widget_item_with_tax
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should show tax information
+ # (could be "incl." or "plus" depending on settings)
+ # Looking for "19" and "%" near each other
+ expect(item_elem.locator('text=/19.*%|%.*19/')).to_be_visible()
+
+ def test_items_without_tax_no_tax_line(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Items without tax rules should not show tax information.
+
+ Tax line should be absent for tax-free items.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # widget_items by default have no tax
+ # We just verify the items display without errors
+ for item in widget_items:
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestDiscountedPricing:
+ """Test display of discounted prices."""
+
+ def test_widget_displays_prices_without_errors(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget should display all prices without errors.
+
+ This is a smoke test to ensure price rendering works.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # All items should display their prices
+ for item in widget_items:
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should have USD currency and price displayed
+ expect(item_elem.locator('text=/USD/')).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestPriceForVariations:
+ """Test price display for items with variations."""
+
+ def test_variation_price_range_when_collapsed(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_variations,
+ widget_page
+ ):
+ """
+ Collapsed variations should show price range (min - max).
+
+ E.g., "$20.00 - $30.00" for variations from $20 to $30.
+ """
+ item, _ = widget_item_with_variations
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Variations are collapsed by default
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should show price range in main row (not in hidden variations)
+ # Format: USD 20.00 – 30.00 (en-dash, not hyphen)
+ # Look specifically in the main row's price column
+ main_row = item_elem.locator('.pretix-widget-main-item-row')
+ price_col = main_row.locator('.pretix-widget-item-price-col')
+ expect(
+ price_col.locator('text=/USD.*20\\.00/')
+ ).to_be_visible()
+ expect(price_col.locator('text=/30\\.00/')).to_be_visible()
+
+ def test_expanded_variations_show_individual_prices(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_variations,
+ widget_page
+ ):
+ """
+ Expanded variations show individual prices for each variation.
+
+ Each size should show its own price.
+ """
+ item, variations = widget_item_with_variations
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Expand variations
+ widget_page.expand_variations(item.name)
+
+ # Check each variation shows a price with USD currency
+ # We don't check exact amounts because formatting may vary
+ for var in variations:
+ var_elem = page.locator(
+ f'.pretix-widget-variation:has('
+ f'strong:text-is("{var.value}"))'
+ )
+ expect(var_elem).to_be_visible()
+
+ # Should contain USD currency code
+ expect(var_elem.locator('text=/USD/')).to_be_visible()
diff --git a/src/tests/e2e/test_widget_quantity_controls.py b/src/tests/e2e/test_widget_quantity_controls.py
new file mode 100644
index 0000000000..f049d3cc94
--- /dev/null
+++ b/src/tests/e2e/test_widget_quantity_controls.py
@@ -0,0 +1,354 @@
+"""
+E2E Tests for Quantity Controls & Order Limits
+
+Tests that verify:
+- Checkbox display for order_max=1 items
+- +/- buttons for multi-quantity items
+- Order minimum/maximum enforcement
+- Auto-selection of single items
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestQuantityControls:
+ """Test quantity control UI elements."""
+
+ def test_checkbox_for_single_select_item(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_single_select,
+ widget_page
+ ):
+ """
+ Item with order_max=1 should show checkbox instead of quantity input.
+
+ For items limited to 1 per order, a checkbox is more intuitive than
+ a number input.
+ """
+ item = widget_item_single_select
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Find the item
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should have checkbox, NOT number input
+ expect(item_elem.locator('input[type="checkbox"]')).to_be_visible()
+ expect(item_elem.locator('input[type="number"]')).not_to_be_visible()
+
+ def test_checkbox_selection(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_single_select,
+ widget_page
+ ):
+ """
+ Checking the checkbox should select the item.
+
+ User can check/uncheck to add/remove the item.
+
+ Note: When there's only one item, it may be auto-selected by the widget.
+ This test verifies the check/uncheck functionality works regardless.
+ """
+ item = widget_item_single_select
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")')
+ checkbox = item_elem.locator('input[type="checkbox"]')
+
+ # Ensure checkbox is unchecked to start test
+ if checkbox.is_checked():
+ checkbox.uncheck()
+ expect(checkbox).not_to_be_checked()
+
+ # Check it
+ checkbox.check()
+ expect(checkbox).to_be_checked()
+
+ # Uncheck it
+ checkbox.uncheck()
+ expect(checkbox).not_to_be_checked()
+
+ def test_plus_minus_buttons_for_multi_quantity(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Items with order_max > 1 should have +/- buttons.
+
+ Plus/minus buttons provide an easy way to increment/decrement quantity.
+ """
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Regular items should have number input with +/- buttons
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{widget_items[0].name}")')
+
+ # Should have number input
+ expect(item_elem.locator('input[type="number"]')).to_be_visible()
+
+ # Should have + and - buttons
+ expect(item_elem.locator('button:has-text("+")')).to_be_visible()
+ expect(item_elem.locator('button:has-text("-")')).to_be_visible()
+
+ def test_increment_button_functionality(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Clicking + button should increase quantity by 1.
+
+ Each click increments the value in the number input.
+ """
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{widget_items[0].name}")')
+ number_input = item_elem.locator('input[type="number"]')
+ plus_button = item_elem.locator('button:has-text("+")').first
+
+ # Initial value should be 0 or empty
+ initial_value = number_input.input_value()
+ if not initial_value:
+ initial_value = "0"
+
+ # Click +
+ plus_button.click()
+ page.wait_for_timeout(100)
+
+ # Should be incremented
+ expect(number_input).to_have_value("1")
+
+ # Click + again
+ plus_button.click()
+ page.wait_for_timeout(100)
+
+ expect(number_input).to_have_value("2")
+
+ def test_decrement_button_functionality(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Clicking - button should decrease quantity by 1.
+
+ Quantity should not go below 0.
+ """
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{widget_items[0].name}")')
+ number_input = item_elem.locator('input[type="number"]')
+ plus_button = item_elem.locator('button:has-text("+")').first
+ minus_button = item_elem.locator('button:has-text("-")').first
+
+ # Set to 2
+ plus_button.click()
+ page.wait_for_timeout(100)
+ plus_button.click()
+ page.wait_for_timeout(100)
+
+ expect(number_input).to_have_value("2")
+
+ # Click -
+ minus_button.click()
+ page.wait_for_timeout(100)
+
+ expect(number_input).to_have_value("1")
+
+ # Click - again
+ minus_button.click()
+ page.wait_for_timeout(100)
+
+ expect(number_input).to_have_value("0")
+
+ def test_manual_quantity_input(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ User should be able to type quantity directly.
+
+ Number input should accept typed values.
+ """
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Directly set quantity using helper
+ widget_page.select_item_quantity(widget_items[0].name, 5)
+
+ # Verify value
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{widget_items[0].name}")')
+ number_input = item_elem.locator('input[type="number"]')
+ expect(number_input).to_have_value("5")
+
+
+@pytest.mark.django_db
+class TestOrderLimits:
+ """Test order minimum and maximum enforcement."""
+
+ def test_order_max_enforces_checkbox_for_single(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_single_select,
+ widget_page
+ ):
+ """Item with order_max=1 should show checkbox (implicit max enforcement)."""
+ item = widget_item_single_select # This has order_max=1
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+ # order_max=1 items get a checkbox (max is enforced by being binary)
+ expect(item_elem.locator('input[type="checkbox"]')).to_be_visible()
+ # No number input should exist
+ expect(item_elem.locator('input[type="number"]')).not_to_be_visible()
+
+ def test_submit_with_multiple_items_opens_checkout(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """Multiple items with different quantities should open iframe checkout."""
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ widget_page.select_item_quantity(widget_items[0].name, 2)
+ widget_page.select_item_quantity(widget_items[1].name, 1)
+
+ widget_page.click_buy_button()
+
+ # Iframe checkout should open
+ widget_page.wait_for_iframe_checkout()
+
+ iframe_elem = page.locator('iframe[name^="pretix-widget-"]')
+ src = iframe_elem.get_attribute('src')
+ assert 'take_cart_id' in src
+
+
+@pytest.mark.django_db
+class TestFreePrice:
+ """Test pay-what-you-want (free price) items."""
+
+ def test_free_price_input_appears(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_free_price,
+ widget_page
+ ):
+ """
+ Free price item should show price input field.
+
+ User should be able to enter their own price.
+ """
+ item = widget_item_free_price
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should show a price input (type=number for price)
+ price_inputs = item_elem.locator('.pretix-widget-pricebox-price-input, input[name^="price_"]')
+ expect(price_inputs.first).to_be_visible()
+
+ def test_free_price_minimum_enforcement(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_free_price,
+ widget_page
+ ):
+ """
+ Free price input should have minimum value set.
+
+ The min attribute should be set to the item's default price.
+ """
+ item = widget_item_free_price
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")')
+ price_input = item_elem.locator('.pretix-widget-pricebox-price-input, input[name^="price_"]').first
+
+ # Should have min attribute
+ min_value = price_input.get_attribute('min')
+ assert min_value is not None
+ assert float(min_value) == float(item.default_price)
+
+ def test_free_price_custom_amount(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_free_price,
+ widget_page
+ ):
+ """
+ User should be able to enter custom price amount.
+
+ Amount above minimum should be accepted.
+ """
+ item = widget_item_free_price
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")')
+ price_input = item_elem.locator('.pretix-widget-pricebox-price-input, input[name^="price_"]').first
+
+ # Enter custom amount
+ price_input.fill("25.00")
+
+ # Verify value
+ expect(price_input).to_have_value("25.00")
diff --git a/src/tests/e2e/test_widget_responsive.py b/src/tests/e2e/test_widget_responsive.py
new file mode 100644
index 0000000000..2cb0842275
--- /dev/null
+++ b/src/tests/e2e/test_widget_responsive.py
@@ -0,0 +1,96 @@
+"""
+E2E Tests for Responsive Behavior
+
+Tests that verify:
+- Mobile layout at narrow widths (pretix-widget-mobile class)
+- Layout updates on resize
+- Desktop layout at wide widths
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestResponsiveLayout:
+ """Test responsive layout behavior."""
+
+ def test_mobile_class_at_narrow_width(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget should add pretix-widget-mobile class when
+ container width <= 800px.
+ """
+ # Set narrow viewport
+ page.set_viewport_size({"width": 375, "height": 667})
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Should have mobile class
+ expect(page.locator('.pretix-widget-mobile')).to_be_visible()
+
+ def test_no_mobile_class_at_wide_width(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget should NOT have pretix-widget-mobile class when
+ container width > 800px.
+ """
+ # Ensure wide viewport (default is 1280)
+ page.set_viewport_size({"width": 1280, "height": 720})
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Should NOT have mobile class
+ expect(page.locator('.pretix-widget-mobile')).to_have_count(0)
+
+ # But widget itself should be present
+ expect(page.locator('.pretix-widget')).to_be_visible()
+
+ def test_responsive_layout_updates_on_resize(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Widget should switch to mobile layout when browser is resized
+ from desktop to mobile width.
+ """
+ # Start with desktop viewport
+ page.set_viewport_size({"width": 1280, "height": 720})
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Should not be mobile
+ expect(page.locator('.pretix-widget-mobile')).to_have_count(0)
+
+ # Resize to mobile
+ page.set_viewport_size({"width": 375, "height": 667})
+
+ # Wait for ResizeObserver to fire
+ page.wait_for_timeout(500)
+
+ # Should now have mobile class
+ expect(page.locator('.pretix-widget-mobile')).to_be_visible()
diff --git a/src/tests/e2e/test_widget_variations.py b/src/tests/e2e/test_widget_variations.py
new file mode 100644
index 0000000000..f78e010283
--- /dev/null
+++ b/src/tests/e2e/test_widget_variations.py
@@ -0,0 +1,242 @@
+"""
+E2E Tests for Product Variations
+
+Tests that verify items with variations (sizes, colors, etc.) work correctly:
+- Expand/collapse behavior
+- Price ranges
+- Individual variation selection
+- Auto-expand when filtered
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestProductVariations:
+ """Test product variation functionality."""
+
+ def test_item_with_variations_shows_toggle_button(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_variations,
+ widget_page
+ ):
+ """
+ Item with variations should show expand/collapse button.
+
+ Variations should be collapsed by default with a button to expand them.
+ """
+ item, variations = widget_item_with_variations
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Should show item name
+ expect(page.locator(f'text="{item.name}"')).to_be_visible()
+
+ # Should show variations toggle button
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")')
+ toggle_btn = item_elem.locator('button:has-text("variants"), button:has-text("Show variants")')
+ expect(toggle_btn.first).to_be_visible()
+
+ def test_expand_variations_on_click(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_variations,
+ widget_page
+ ):
+ """
+ Clicking toggle button should expand variations.
+
+ After expanding, all variation options should be visible.
+ """
+ item, variations = widget_item_with_variations
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Expand variations
+ widget_page.expand_variations(item.name)
+
+ # Wait for variations to be visible
+ page.wait_for_timeout(500) # Wait for animation
+
+ # All variations should now be visible
+ for variation in variations:
+ expect(page.locator(f'text="{variation.value}"')).to_be_visible()
+
+ def test_collapse_variations_on_second_click(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_variations,
+ widget_page
+ ):
+ """Clicking toggle again should collapse variations."""
+ item, variations = widget_item_with_variations
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")')
+ toggle_btn = item_elem.locator('button:has-text("variants"), button:has-text("Show variants")')
+
+ # First click - expand
+ toggle_btn.first.click()
+ page.wait_for_timeout(500)
+
+ # Variations should be visible
+ for variation in variations:
+ expect(page.locator(f'text="{variation.value}"')).to_be_visible()
+
+ # Second click - collapse
+ toggle_btn.first.click()
+ page.wait_for_timeout(500)
+
+ # Variation inputs should no longer be visible
+ for variation in variations:
+ var_row = item_elem.locator(
+ f'.pretix-widget-variation:has(strong:text-is("{variation.value}"))')
+ expect(var_row.locator('input')).not_to_be_visible()
+
+ def test_price_range_for_collapsed_variations(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_variations,
+ widget_page
+ ):
+ """
+ Collapsed variations should show price range.
+
+ When variations have different prices, should display range like "$20 - $30".
+ """
+ item, variations = widget_item_with_variations
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Should show price range
+ # Variations go from $20 to $30
+ item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")')
+
+ # Look for price range indicators
+ # Format is "USD 20.00 – 30.00" in the main item's price box (first one)
+ price_box = item_elem.locator('.pretix-widget-pricebox').first
+ expect(price_box).to_contain_text('20.00')
+ expect(price_box).to_contain_text('30.00')
+
+ def test_select_variation_quantity(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_variations,
+ widget_page
+ ):
+ """
+ User should be able to select quantity for specific variation.
+
+ After expanding variations, each should have its own quantity selector.
+ """
+ item, variations = widget_item_with_variations
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Expand variations
+ widget_page.expand_variations(item.name)
+ page.wait_for_timeout(500)
+
+ # Select quantity for "Medium" variation
+ widget_page.select_variation_quantity(item.name, "Medium", 2)
+
+ # Verify input has the value
+ medium_var = page.locator('.pretix-widget-variation:has-text("Medium")')
+ input_field = medium_var.locator('input[type="number"]')
+ expect(input_field).to_have_value("2")
+
+ def test_variation_individual_prices(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_variations,
+ widget_page
+ ):
+ """
+ Each variation should show its individual price.
+
+ When expanded, variations should display their specific prices.
+ """
+ item, variations = widget_item_with_variations
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Expand variations
+ widget_page.expand_variations(item.name)
+ page.wait_for_timeout(500)
+
+ # Check each variation shows its price
+ variation_prices = {
+ 'Small': '20.00',
+ 'Medium': '25.00',
+ 'Large': '25.00',
+ 'X-Large': '30.00',
+ }
+
+ for var_name, price in variation_prices.items():
+ # Use exact heading match to avoid "Large" matching "X-Large"
+ var_elem = page.locator(
+ f'.pretix-widget-variation:has(strong:text-is("{var_name}"))'
+ )
+ expect(var_elem).to_be_visible()
+ # Price should be visible within the variation element
+ expect(var_elem.locator(f'text=/{price}/')).to_be_visible()
+
+
+@pytest.mark.django_db
+class TestVariationSubmission:
+ """Test that variation selections submit correctly."""
+
+ def test_submit_with_variation_selection(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_with_variations,
+ widget_page
+ ):
+ """Submitting with a variation selected should open iframe checkout."""
+ item, variations = widget_item_with_variations
+
+ widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Expand and select a variation
+ widget_page.expand_variations(item.name)
+ page.wait_for_timeout(500)
+ widget_page.select_variation_quantity(item.name, "Large", 1)
+
+ widget_page.click_buy_button()
+
+ # Iframe checkout should open with the variation in the cart
+ widget_page.wait_for_iframe_checkout()
+
+ iframe_elem = page.locator('iframe[name^="pretix-widget-"]')
+ src = iframe_elem.get_attribute('src')
+ assert 'take_cart_id' in src
diff --git a/src/tests/e2e/test_widget_vouchers.py b/src/tests/e2e/test_widget_vouchers.py
new file mode 100644
index 0000000000..5fbde5cb4d
--- /dev/null
+++ b/src/tests/e2e/test_widget_vouchers.py
@@ -0,0 +1,135 @@
+"""
+E2E Tests for Voucher Redemption
+
+Tests that verify:
+- Voucher input field appears when vouchers exist
+- Voucher redemption flow works
+- Voucher input hidden when disable-vouchers is set
+- Voucher explanation text displays
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestVoucherDisplay:
+ """Test voucher input rendering."""
+
+ def test_voucher_input_appears_when_vouchers_exist(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_voucher,
+ widget_page
+ ):
+ """
+ Voucher input field should be visible when event has vouchers.
+
+ The widget checks `vouchers_exist` in the API response.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Voucher section should be visible
+ voucher_section = page.locator('.pretix-widget-voucher')
+ expect(voucher_section).to_be_visible()
+
+ # Should have the "Redeem a voucher" heading
+ expect(page.locator(
+ '.pretix-widget-voucher-headline'
+ )).to_be_visible()
+
+ # Should have the voucher input
+ expect(page.locator(
+ '.pretix-widget-voucher-input'
+ )).to_be_visible()
+
+ def test_voucher_input_hidden_when_no_vouchers(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_items,
+ widget_page
+ ):
+ """
+ Voucher input should not appear when no vouchers exist.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Voucher section should NOT be visible
+ voucher_section = page.locator('.pretix-widget-voucher')
+ expect(voucher_section).to_have_count(0)
+
+ def test_voucher_explanation_text_displays(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_voucher,
+ widget_page
+ ):
+ """
+ Voucher explanation text should display when configured.
+ """
+ # Set voucher explanation text on the event
+ widget_event.settings.set(
+ 'voucher_explanation_text',
+ 'Enter your voucher code to get a discount.'
+ )
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Explanation text should be visible
+ explanation = page.locator('.pretix-widget-voucher-text')
+ expect(explanation).to_be_visible()
+ expect(explanation).to_contain_text('Enter your voucher code')
+
+
+@pytest.mark.django_db
+class TestVoucherRedemption:
+ """Test voucher redemption flow."""
+
+ def test_redeem_voucher_opens_checkout(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_voucher,
+ widget_page
+ ):
+ """
+ Entering a voucher code and clicking Redeem should open checkout.
+
+ With skip-ssl-check (added by test harness), this opens in iframe.
+ """
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Enter voucher code
+ voucher_input = page.locator('.pretix-widget-voucher-input')
+ voucher_input.fill('TESTCODE2024')
+
+ # Click the Redeem button
+ page.locator(
+ '.pretix-widget-voucher-button-wrap button'
+ ).click()
+
+ # With skip-ssl-check, voucher redemption opens in iframe
+ iframe = widget_page.wait_for_iframe_checkout()
+
+ # The iframe src should contain the voucher code
+ iframe_elem = page.locator('iframe[name^="pretix-widget-"]')
+ src = iframe_elem.get_attribute('src')
+ assert 'TESTCODE2024' in src or 'voucher' in src
diff --git a/src/tests/e2e/test_widget_waitinglist.py b/src/tests/e2e/test_widget_waitinglist.py
new file mode 100644
index 0000000000..6dfc496c09
--- /dev/null
+++ b/src/tests/e2e/test_widget_waitinglist.py
@@ -0,0 +1,76 @@
+"""
+E2E Tests for Waiting List Integration
+
+Tests that verify:
+- Waiting list link appears for sold out items when enabled
+- Waiting list link URL includes correct parameters
+"""
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.mark.django_db
+class TestWaitingList:
+ """Test waiting list display for sold out items."""
+
+ def test_waiting_list_link_appears_when_sold_out(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_sold_out_with_waitinglist,
+ widget_page
+ ):
+ """
+ Sold out items with waiting list enabled should show
+ a "Waiting list" link.
+
+ Requires:
+ - Event setting: waiting_list_enabled = True
+ - Item: allow_waitinglist = True
+ - Item availability < 100 (sold out)
+ """
+ item = widget_item_sold_out_with_waitinglist
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ # Find the sold out item
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ expect(item_elem).to_be_visible()
+
+ # Should show waiting list link
+ waiting_list = item_elem.locator(
+ '.pretix-widget-waiting-list-link a')
+ expect(waiting_list).to_be_visible()
+
+ def test_waiting_list_link_url_contains_item_id(
+ self,
+ page: Page,
+ live_server_url: str,
+ widget_organizer,
+ widget_event,
+ widget_item_sold_out_with_waitinglist,
+ widget_page
+ ):
+ """
+ Waiting list link URL should include item ID parameter.
+ """
+ item = widget_item_sold_out_with_waitinglist
+
+ widget_page.goto_widget_test_page(
+ live_server_url, widget_organizer.slug, widget_event.slug)
+ widget_page.wait_for_widget_load()
+
+ item_elem = page.locator(
+ f'.pretix-widget-item:has-text("{item.name}")')
+ waiting_list_link = item_elem.locator(
+ '.pretix-widget-waiting-list-link a')
+
+ href = waiting_list_link.get_attribute('href')
+ assert href is not None
+ assert f'item={item.pk}' in href
+ assert 'waitinglist' in href
diff --git a/src/tests/e2e/widget_test_page.html b/src/tests/e2e/widget_test_page.html
new file mode 100644
index 0000000000..3f17e97640
--- /dev/null
+++ b/src/tests/e2e/widget_test_page.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Widget Test Page
+
+
+ Pretix Widget E2E Test Page
+
+
+
+
+
+
+
+