"""Structural and Playwright tests for the generated order form.""" from __future__ import annotations import base64 import json import re import sys from pathlib import Path from types import SimpleNamespace import pytest REPO_ROOT = Path(__file__).resolve().parents[1] SERVER_DIR = REPO_ROOT / "src" / "server" FIXTURES = Path(__file__).resolve().parent / "fixtures" sys.path.insert(0, str(SERVER_DIR)) import generate_form as generate_form_module # noqa: E402 generate_form = generate_form_module.generate_form EXPECTED_CONFIG_KEYS = { "authMode", "meals", "roster", "week", "submitUrl", "statusUrl", "rosterUrl", "adminUrl", "adminPdfUrl", "bulkDiscount", "companySubsidy", "googleClientId", "deadline", "scrapedAt", "hasDiscount", } def _load_fixtures(): menu = json.loads((FIXTURES / "menu.json").read_text()) config = json.loads((FIXTURES / "config.json").read_text()) return menu, config def _extract_config(html: str) -> dict: match = re.search( r"window\.CONFIG\s*=\s*(\{.*?\});", html, flags=re.DOTALL, ) assert match, "window.CONFIG blob not found" return json.loads(match.group(1)) def _render(*, google: bool = False) -> str: menu, config = _load_fixtures() return generate_form( menu, config, api_url="https://api.example.com" if google else "", bulk_discount=10, company_subsidy=50, google_client_id="google-client.apps.googleusercontent.com" if google else "", ) def _render_for_browser(*, google: bool = False) -> str: html = _render(google=google) if not google: html = html.replace("", '', 1) return html def _google_credential() -> str: payload = json.dumps( { "name": "Test Admin", "email": "test-admin@example.com", "picture": ( "data:image/svg+xml," "" ), } ).encode() encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=") return f"e30.{encoded}.signature" def _mock_form_routes(page): page.route( "**/api/form-status/**", lambda route: route.fulfill( status=200, content_type="application/json", body='{"status":"open"}', ), ) page.route( "**/api/roster**", lambda route: route.fulfill( status=200, content_type="application/json", body=json.dumps( {"employees": [{"name": "Ada Lovelace", "email": "ada@example.com"}]} ), ), ) page.route( "**/api/admin/orders", lambda route: route.fulfill( status=403, content_type="application/json", body='{"error":"Forbidden"}', ), ) class TestGenerateFormStructural: def test_cloud_mode_requires_google_auth(self): menu, config = _load_fixtures() for client_id in ("", " "): with pytest.raises(ValueError, match="Google client ID is required"): generate_form( menu, config, api_url="https://api.example.com", google_client_id=client_id, ) def test_cloud_cli_rejects_whitespace_ssm_client_id(self, monkeypatch, capsys): menu, config = _load_fixtures() ssm = SimpleNamespace(get_parameter=lambda **_: {"Parameter": {"Value": " "}}) monkeypatch.setitem(sys.modules, "boto3", SimpleNamespace(client=lambda _: ssm)) monkeypatch.setattr(generate_form_module, "load_config", lambda: config) monkeypatch.setattr(generate_form_module, "latest_menu", lambda: menu) monkeypatch.setattr( sys, "argv", ["generate_form.py", "--api-url", "https://api.example.com"], ) with pytest.raises(SystemExit) as exc_info: generate_form_module.main() assert exc_info.value.code == 2 assert "Google auth is required in cloud mode" in capsys.readouterr().err def test_generated_form_contains_no_api_key_handling(self): html = _render(google=True) assert "apiKey" not in _extract_config(html) assert "API_KEY" not in html assert "x-api-key" not in html def test_cloud_configuration_has_no_form_api_key(self): template = (REPO_ROOT / "template.yaml").read_text() workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text() readme = (REPO_ROOT / "README.md").read_text() for text in (template, workflow): assert "FORM_APIKEY" not in text assert "form-api-key" not in text assert "x-api-key" not in text assert "--api-key" not in text assert ( "`meal-order-manager/form-api-key` secret remains until this change " "is deployed and verified" ) in readme def test_submit_route_has_explicit_throttling(self): template = (REPO_ROOT / "template.yaml").read_text() assert "'POST /api/submit-order':" in template submit_settings = template.split("'POST /api/submit-order':", 1)[1].split( "CorsConfiguration:", 1 )[0] assert "ThrottlingBurstLimit: 10" in submit_settings assert "ThrottlingRateLimit: 5" in submit_settings def test_weekly_menu_uses_iam_protected_api_without_dynamodb(self): template = (REPO_ROOT / "template.yaml").read_text() workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text() script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text() for route in ("/api/publish/settings", "/api/publish/menu"): next_event = ( " PublishMenu:" if route == "/api/publish/settings" else " AdminOrders:" ) route_config = template.split(f"Path: {route}", 1)[1].split(next_event, 1)[ 0 ] assert "Authorizer: AWS_IAM" in route_config assert route in script assert "scripts/upload_menu.py settings" in workflow assert "scripts/upload_menu.py publish" in workflow assert "aws dynamodb" not in workflow assert 'boto3.resource("dynamodb")' not in script def test_local_and_google_render(self): local = _render(google=False) google = _render(google=True) assert "FORM_HTML_MARKER" in local assert "FORM_HTML_MARKER" in google assert 'id="emp-name"' in local assert 'id="emp-name"' not in google assert "accounts.google.com/gsi/client" in google assert "accounts.google.com/gsi/client" not in local assert 'id="auth-overlay"' in google assert 'id="auth-overlay"' not in local def test_single_config_blob_and_keys(self): for google in (False, True): html = _render(google=google) assert html.count("window.CONFIG =") == 1 cfg = _extract_config(html) assert EXPECTED_CONFIG_KEYS <= set(cfg.keys()) assert cfg["authMode"] == ("google" if google else "local") if google: assert cfg["googleClientId"] else: assert cfg["googleClientId"] == "" assert cfg["bulkDiscount"] == 10 assert cfg["companySubsidy"] == 50 assert cfg["hasDiscount"] is True def test_no_legacy_const_injections(self): html = _render(google=True) for pattern in ( r"\bconst MEALS\s*=", r"\bconst ROSTER\s*=", r"\bconst SUBMIT_URL\s*=", r"\bconst STATUS_URL\s*=", r"\bconst ROSTER_URL\s*=", r"\bconst ADMIN_URL\s*=", r"\bconst ADMIN_PDF_URL\s*=", r"\bconst API_KEY\s*=", r"\bconst GOOGLE_CLIENT_ID\s*=", ): # Locals derived from CONFIG inside form.js are fine; generator must not # inject the seven top-level const lines before the inlined scripts. pre_js = html.split("/* FORM_JS_MARKER */")[0] assert not re.search(pattern, pre_js), pattern def test_template_markers_inlined(self): html = _render() assert "FORM_CSS_MARKER" in html assert "--color-brand" in html assert "FORM_JS_MARKER" in html assert "ADMIN_JS_MARKER" in html assert "FORM_HTML_MARKER" in html assert '" not in html cfg_match = re.search(r"window\.CONFIG\s*=\s*(\{.*?\});", html, flags=re.DOTALL) assert cfg_match blob = cfg_match.group(1) # Literal inside the CONFIG script would terminate early. assert "" not in blob # Parsed JSON still contains the hostile meal name. cfg = json.loads(blob) assert any( m["name"] == "" for m in cfg["meals"] ) def test_hostile_names_present_in_config(self): cfg = _extract_config(_render()) names = {m["name"] for m in cfg["meals"]} assert "Luis' Lomo Saltado" in names assert 'Meal with "quotes"' in names assert "" in names assert "" in names def test_meal_card_template_and_qty_input(self): html = _render() assert 'id="meal-card-tpl"' in html assert 'type="number"' in html assert "data-meal-index" in html assert "data-qty-delta" in html def test_a11y_status_and_live_total_markup(self): html = _render() assert 'id="form-status-alert"' in html assert 'id="form-status-info"' in html assert 'id="admin-status-alert"' in html assert 'id="admin-status-info"' in html assert 'role="alert"' in html assert 'role="status"' in html assert 'aria-live="polite"' in html assert 'id="success-heading"' in html assert 'tabindex="-1"' in html def test_no_alert_dialogs_in_form_scripts(self): form_js = (SERVER_DIR / "templates" / "form.js").read_text() admin_js = (SERVER_DIR / "templates" / "admin.js").read_text() assert "alert(" not in form_js assert "alert(" not in admin_js # confirm() retained for destructive admin delete / zero-qty save. assert "confirm(" in admin_js def test_design_tokens_only_in_definitions(self): css = (SERVER_DIR / "templates" / "form.css").read_text() # Strip :root block, then assert brand/muted/border hexes are gone without_root = re.sub(r":root\s*\{[^}]*\}", "", css, count=1) assert "#1a1a2e" not in without_root assert "#6b7280" not in without_root assert "#d1d5db" not in without_root def test_python_has_no_html_js_fragments(self): src = (SERVER_DIR / "generate_form.py").read_text() assert "auth_section_html" not in src assert "google_auth_js" not in src assert "submit_order_js" not in src assert "origLoadWeekOrders" not in src class TestGenerateFormPlaywright: @pytest.fixture def browser_page(self, tmp_path_factory): sync_api = pytest.importorskip("playwright.sync_api") sync_playwright = sync_api.sync_playwright html = _render_for_browser(google=False) # Stub status/roster so the page does not hang on network; roster fallback # in loadRoster still applies if fetch fails — intercept to be deterministic. out = tmp_path_factory.mktemp("form") / "order-form.html" out.write_text(html) with sync_playwright() as p: try: browser = p.chromium.launch(headless=True) except Exception as exc: raise RuntimeError( "Chromium is required for form Playwright tests. " "Run: playwright install --with-deps chromium" ) from exc try: page = browser.new_page() _mock_form_routes(page) page.goto(out.as_uri()) page.wait_for_selector('[data-meal-index="0"]') yield page finally: browser.close() @pytest.mark.parametrize("width", [320, 375, 480]) @pytest.mark.parametrize("google", [False, True], ids=["local", "google"]) def test_sticky_footer_fits_narrow_viewports(self, tmp_path, width, google): sync_api = pytest.importorskip("playwright.sync_api") sync_playwright = sync_api.sync_playwright html_path = tmp_path / f"order-form-{'google' if google else 'local'}.html" html_path.write_text(_render_for_browser(google=google)) with sync_playwright() as p: try: browser = p.chromium.launch(headless=True) except Exception as exc: raise RuntimeError( "Chromium is required for form Playwright tests. " "Run: playwright install --with-deps chromium" ) from exc try: page = browser.new_page(viewport={"width": width, "height": 800}) page.add_init_script( """window.google = { accounts: { id: { initialize() {}, renderButton() {}, disableAutoSelect() {}, }, }, };""" ) page.route( "https://accounts.google.com/gsi/client", lambda route: route.abort(), ) _mock_form_routes(page) page.goto(html_path.as_uri(), wait_until="domcontentloaded") page.wait_for_selector('[data-meal-index="0"]', state="attached") if google: page.evaluate( "(credential) => handleCredentialResponse({ credential })", _google_credential(), ) page.wait_for_selector('[data-meal-index="0"]', state="visible") first_increment = page.locator( '[data-meal-index="0"] [data-qty-delta="1"]' ) first_increment.click() first_increment.click() page.wait_for_function( "() => document.getElementById('count-display').textContent === " "'2 meals'" ) page.evaluate("() => document.fonts.ready") total = page.locator("#total-display") count = page.locator("#count-display") submit = page.locator("#submit-btn") assert total.inner_text() == "$10.80" assert count.inner_text() == "2 meals" assert total.is_visible() assert count.is_visible() assert submit.is_visible() assert submit.is_enabled() submit.focus() assert submit.evaluate( "(element) => element === document.activeElement" ) page.evaluate( "() => window.scrollTo(0, document.documentElement.scrollHeight)" ) layout = page.evaluate( """() => { const rect = (selector) => { const box = document.querySelector(selector) .getBoundingClientRect(); return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, }; }; const footer = document.querySelector('#sticky-footer'); return { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, viewportHeight: window.innerHeight, footer: { ...rect('#sticky-footer'), position: getComputedStyle(footer).position, }, total: rect('#total-display'), count: rect('#count-display'), submit: rect('#submit-btn'), lastCard: rect('#meals-list .meal-card:last-child'), }; }""" ) assert layout["scrollWidth"] == layout["clientWidth"] for selector in ("footer", "total", "count", "submit"): assert layout[selector]["left"] >= 0 assert layout[selector]["right"] <= layout["clientWidth"] assert layout["footer"]["position"] == "fixed" assert layout["footer"]["top"] >= 0 assert layout["footer"]["bottom"] <= layout["viewportHeight"] assert layout["lastCard"]["bottom"] <= layout["footer"]["top"] finally: browser.close() def test_admin_bundle_loads(self, browser_page): assert browser_page.evaluate("typeof window.loadWeekOrders") == "function" def test_qty_plus_updates_total_without_list_teardown(self, browser_page): page = browser_page card = page.locator('[data-meal-index="0"]') card_handle = card.element_handle() assert card_handle is not None page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click() page.wait_for_function( "() => document.getElementById('total-display').textContent !== '$0.00'" ) assert page.locator("#total-display").inner_text() == "$5.40" assert page.locator("#count-display").inner_text() == "1 meal" assert page.locator('[data-meal-index="0"] .qty').input_value() == "1" assert "selected" in (card.get_attribute("class") or "") # Same DOM node — partial update, not full list teardown page.evaluate( """() => { const el = document.querySelector('[data-meal-index="0"]'); el.dataset.teardownProbe = '1'; }""" ) page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click() page.wait_for_function( "() => document.querySelector('[data-meal-index=\"0\"] .qty').value === '2'" ) assert ( page.locator('[data-meal-index="0"]').get_attribute("data-teardown-probe") == "1" ) assert page.locator("#count-display").inner_text() == "2 meals" assert page.locator("#total-display").inner_text() == "$10.80" def test_qty_labels_present_after_init(self, browser_page): page = browser_page card = page.locator('[data-meal-index="0"]') assert ( card.locator('[data-qty-delta="-1"]').get_attribute("aria-label") == "Remove one Chicken Bowl" ) assert ( card.locator('[data-qty-delta="1"]').get_attribute("aria-label") == "Add one Chicken Bowl" ) assert ( card.locator(".qty").get_attribute("aria-label") == "Quantity of Chicken Bowl" ) def test_filter_chip_aria_pressed(self, browser_page): page = browser_page all_btn = page.locator("#filters button", has_text="All Meals") assert all_btn.get_attribute("aria-pressed") == "true" other = page.locator("#filters button").nth(1) other_label = other.inner_text() other.click() page.wait_for_function( """(label) => { const active = document.querySelector('#filters button.active'); return active && active.textContent === label && active.getAttribute('aria-pressed') === 'true'; }""", arg=other_label, ) assert all_btn.get_attribute("aria-pressed") == "false" assert other.get_attribute("aria-pressed") == "true" all_btn.click() page.wait_for_function( """() => { const active = document.querySelector('#filters button.active'); return active && active.textContent === 'All Meals' && active.getAttribute('aria-pressed') === 'true'; }""" ) def test_desc_more_only_when_overflow(self, browser_page): page = browser_page # Ensure All Meals filter so both short and long cards are visible. page.locator("#filters button", has_text="All Meals").click() page.wait_for_selector('[data-meal-index="0"]') page.wait_for_selector('[data-meal-index="1"]') short_card = page.locator('[data-meal-index="0"]') long_card = page.locator('[data-meal-index="1"]') page.wait_for_function( """() => { const long = document.querySelector('[data-meal-index="1"] .meal-desc-toggle'); return !!long; }""" ) assert short_card.locator(".meal-desc-toggle").count() == 0 toggle = long_card.locator(".meal-desc-toggle") assert toggle.inner_text() == "More" assert toggle.get_attribute("aria-expanded") == "false" toggle.click() assert toggle.inner_text() == "Less" assert toggle.get_attribute("aria-expanded") == "true" assert "is-expanded" in ( long_card.locator(".meal-desc").get_attribute("class") or "" ) def test_submit_failure_lands_in_alert_region(self, browser_page): page = browser_page page.route( "**/api/submit-order**", lambda route: route.fulfill( status=500, content_type="application/json", body='{"error":"Simulated submit failure"}', ), ) page.locator("#emp-name").select_option(label="Ada Lovelace") page.evaluate( """() => { document.querySelectorAll('[data-meal-index] .qty').forEach((input) => { input.value = '0'; input.dispatchEvent(new Event('change', { bubbles: true })); }); }""" ) page.wait_for_function( "() => document.getElementById('count-display').textContent === '0 meals'" ) page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click() page.wait_for_function( "() => document.getElementById('count-display').textContent === '1 meal'" ) page.locator("#submit-btn").click() page.wait_for_function( """() => document.getElementById('form-status-alert').textContent === 'Simulated submit failure'""" ) assert ( page.locator("#form-status-alert").inner_text() == "Simulated submit failure" ) assert page.locator("#form-status-info").inner_text() == "" page.unroute("**/api/submit-order**") def test_qty_typein_clamps_negative(self, browser_page): page = browser_page # Reset first card via type-in qty = page.locator('[data-meal-index="0"] .qty') qty.fill("-3") qty.blur() page.wait_for_function( "() => document.querySelector('[data-meal-index=\"0\"] .qty').value === '0'" ) assert qty.input_value() == "0" qty.fill("2") qty.blur() page.wait_for_function( "() => document.getElementById('count-display').textContent === '2 meals'" ) assert page.locator("#total-display").inner_text() == "$10.80" @pytest.mark.parametrize("width", [320, 375, 479]) def test_local_roster_has_no_horizontal_overflow(self, browser_page, width): page = browser_page page.set_viewport_size({"width": width, "height": 800}) dimensions = page.evaluate( """() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })""" ) assert dimensions["scrollWidth"] == dimensions["clientWidth"] @pytest.fixture def mobile_admin_page(self, tmp_path, browser_page): html = _render_for_browser(google=False) out = tmp_path / "admin-order-form.html" out.write_text(html) hostile_name = """O'Reilly "Ops\"""" state = { "fail_put": False, "dialogs": [], "orders": [ { "employee_name": hostile_name, "employee_email": "hostile@example.com", "submitted_at": "2026-07-30T14:15:00Z", "items": [ { "name": "Chicken Bowl", "quantity": 1, "subtotal": 12.0, } ], "total": 12.0, } ], } prices = { "Chicken Bowl": 12.0, "Luis' Lomo Saltado": 14.5, 'Meal with "quotes"': 11.0, "": 9.0, } def admin_payload(): return { "week": "2026-W30", "orders": state["orders"], "total_employees": len(state["orders"]), "grand_total": sum(order["total"] for order in state["orders"]), } def handle_admin(route): request = route.request if request.method == "PUT": if state["fail_put"]: route.fulfill( status=500, content_type="application/json", body='{"error":"hostile update failed"}', ) return payload = request.post_data_json order = state["orders"][0] order["items"] = [ { "name": item["name"], "quantity": item["quantity"], "subtotal": prices[item["name"]] * item["quantity"], } for item in payload["items"] ] order["total"] = sum(item["subtotal"] for item in order["items"]) route.fulfill( status=200, content_type="application/json", body='{"ok":true}', ) return if request.method == "DELETE": state["orders"] = [] route.fulfill( status=200, content_type="application/json", body='{"ok":true}', ) return route.fulfill( status=200, content_type="application/json", body=json.dumps(admin_payload()), ) browser = browser_page.context.browser assert browser is not None context = browser.new_context( viewport={"width": 375, "height": 800}, has_touch=True, is_mobile=True, ) try: page = context.new_page() page.route( "**/api/form-status/**", lambda route: route.fulfill( status=200, content_type="application/json", body='{"status":"open"}', ), ) page.route( "**/api/roster**", lambda route: route.fulfill( status=200, content_type="application/json", body='{"employees":[]}', ), ) page.route("**/api/admin/orders**", handle_admin) page.on( "dialog", lambda dialog: ( state["dialogs"].append(dialog.message), dialog.accept() if state.get("accept_dialogs", True) else dialog.dismiss(), ), ) page.goto(out.as_uri()) page.evaluate( "() => { window.showAdmin(); window.loadWeekOrders('2026-W30'); }" ) page.wait_for_selector(".admin-order-card") yield page, state, hostile_name finally: context.close() def test_mobile_admin_card_initial_render_and_overflow(self, mobile_admin_page): page, _, hostile_name = mobile_admin_page card = page.locator(".admin-order-card") assert card.is_visible() assert not page.locator(".admin-table-wrap").is_visible() assert hostile_name in card.inner_text() assert "hostile@example.com" in card.inner_text() assert "Chicken Bowl ×1" in card.inner_text() # noqa: RUF001 assert "$12.00" in card.inner_text() submitted_time = card.locator(".admin-card-time") assert submitted_time.is_visible() assert submitted_time.inner_text() == "Jul 30, 10:15 AM" assert page.evaluate( "() => document.documentElement.scrollWidth === " "document.documentElement.clientWidth" ) def test_mobile_admin_card_edit_and_save(self, mobile_admin_page): page, state, hostile_name = mobile_admin_page card = page.locator(".admin-order-card") card.locator('[data-admin-action="start-edit"]').click() page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]') minus = page.locator( '.admin-order-card [data-admin-action="edit-qty"][data-delta="-1"]' ) assert minus.get_attribute("aria-label") == "Remove one Chicken Bowl" assert minus.evaluate("(el) => el.getBoundingClientRect().height") >= 44 add_item = page.locator( '.admin-order-card [data-admin-action="add-item-select"]' ) add_item.select_option(label="Luis' Lomo Saltado ($14.50)") page.wait_for_selector( '.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"]' ) page.locator( '.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"][data-delta="1"]' ).click() page.locator('.admin-order-card [data-admin-action="save-edit"]').click() saved_card = page.locator( ".admin-order-card", has_text="Luis' Lomo Saltado ×2", # noqa: RUF001 ) saved_card.wait_for(state="visible") assert "Luis' Lomo Saltado ×2" in saved_card.inner_text() # noqa: RUF001 assert state["orders"][0]["employee_name"] == hostile_name assert state["orders"][0]["total"] == 41.0 def test_mobile_admin_card_cancel(self, mobile_admin_page): page, _, _ = mobile_admin_page page.locator('.admin-order-card [data-admin-action="start-edit"]').click() page.wait_for_selector('.admin-order-card [data-admin-action="cancel-edit"]') page.locator( '.admin-order-card [data-admin-action="edit-qty"][data-delta="1"]' ).first.click() page.locator('.admin-order-card [data-admin-action="cancel-edit"]').click() assert page.locator( '.admin-order-card [data-admin-action="start-edit"]' ).is_visible() def test_mobile_admin_card_responsive_breakpoints(self, mobile_admin_page): page, _, _ = mobile_admin_page for width in (320, 375, 767): page.set_viewport_size({"width": width, "height": 800}) assert page.locator(".admin-order-card").is_visible() assert page.evaluate( "() => document.documentElement.scrollWidth === " "document.documentElement.clientWidth" ) assert page.evaluate( """() => Array.from(document.querySelectorAll('#admin-panel *')) .filter((el) => el.offsetParent !== null) .every((el) => el.scrollWidth <= el.clientWidth + 1)""" ) page.set_viewport_size({"width": 768, "height": 800}) assert page.locator(".admin-table-wrap").is_visible() assert not page.locator(".admin-order-card").is_visible() def test_mobile_admin_card_save_failure_and_retry(self, mobile_admin_page): page, state, _ = mobile_admin_page state["fail_put"] = True page.locator('.admin-order-card [data-admin-action="start-edit"]').click() page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]') page.locator('.admin-order-card [data-admin-action="save-edit"]').click() page.wait_for_function( "() => document.getElementById('admin-status-alert').textContent" ".includes('hostile update failed')" ) state["fail_put"] = False page.locator('.admin-order-card [data-admin-action="save-edit"]').click() page.wait_for_selector('.admin-order-card [data-admin-action="start-edit"]') assert page.locator("#admin-status-alert").inner_text() == "" def test_mobile_admin_card_delete(self, mobile_admin_page): page, state, hostile_name = mobile_admin_page orders_before = list(state["orders"]) state["accept_dialogs"] = False page.locator('.admin-order-card [data-admin-action="delete"]').click() assert page.locator(".admin-order-card").is_visible() assert state["orders"] == orders_before state["accept_dialogs"] = True page.locator('.admin-order-card [data-admin-action="delete"]').click() page.wait_for_selector("#admin-empty:not(.is-hidden)") assert hostile_name in state["dialogs"][-1] assert state["orders"] == [] class TestGenerateFormGooglePlaywright: @pytest.fixture(scope="class") @classmethod def signed_in_admin_page(cls, tmp_path_factory): try: from playwright.sync_api import sync_playwright except ImportError as exc: # pragma: no cover raise RuntimeError( "playwright package is required; install requirements.txt" ) from exc out = tmp_path_factory.mktemp("google-form") / "order-form.html" out.write_text(_render_for_browser(google=True)) with sync_playwright() as p: try: browser = p.chromium.launch(headless=True) except Exception as exc: raise RuntimeError( "Chromium is required for form Playwright tests. " "Run: playwright install --with-deps chromium" ) from exc try: page = browser.new_page() page.add_init_script( """window.google = { accounts: { id: { initialize() {}, renderButton() {}, disableAutoSelect() {}, }, }, };""" ) page.route( "https://accounts.google.com/gsi/client", lambda route: route.abort(), ) def handle_api(route): if "/api/form-status/" in route.request.url: route.fulfill( status=200, content_type="application/json", body='{"status":"open"}', ) elif "/api/admin/orders" in route.request.url: route.fulfill( status=200, content_type="application/json", body='{"weeks":["2026-W30"]}', ) else: route.fulfill( status=404, content_type="application/json", body="{}", ) page.route("https://api.example.com/**", handle_api) page.goto(out.as_uri(), wait_until="domcontentloaded") page.evaluate( "(credential) => handleCredentialResponse({ credential })", _google_credential(), ) page.locator("#admin-btn").wait_for(state="visible") yield page finally: browser.close() @pytest.mark.parametrize("width", [320, 375]) def test_signed_in_admin_user_bar_fits_phone_width( self, signed_in_admin_page, width ): page = signed_in_admin_page page.set_viewport_size({"width": width, "height": 800}) for selector in ( "#user-avatar", "#user-name", "#user-email", "#admin-btn", "#sign-out-btn", ): locator = page.locator(selector) assert locator.is_visible() bounds = locator.evaluate( """element => { const rect = element.getBoundingClientRect(); return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, }; }""" ) assert bounds["left"] >= 0 assert bounds["top"] >= 0 assert bounds["right"] <= bounds["viewportWidth"] assert bounds["bottom"] <= bounds["viewportHeight"] for selector in ("#user-name", "#user-email"): assert page.locator(selector).evaluate( """element => element.scrollWidth <= element.clientWidth && element.scrollHeight <= element.clientHeight""" ) assert page.locator("#admin-btn").is_enabled() assert page.locator("#sign-out-btn").is_enabled() dimensions = page.evaluate( """() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })""" ) assert dimensions["scrollWidth"] == dimensions["clientWidth"] def test_user_bar_stays_single_row_at_480(self, signed_in_admin_page): page = signed_in_admin_page page.set_viewport_size({"width": 480, "height": 800}) assert ( page.locator(".user-bar").evaluate( "(element) => getComputedStyle(element).flexWrap" ) == "nowrap" ) dimensions = page.evaluate( """() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })""" ) assert dimensions["scrollWidth"] == dimensions["clientWidth"] def test_user_bar_wraps_below_shared_mobile_breakpoint(self, signed_in_admin_page): page = signed_in_admin_page page.set_viewport_size({"width": 479, "height": 800}) assert ( page.locator(".user-bar").evaluate( "(element) => getComputedStyle(element).flexWrap" ) == "wrap" )