2026-07-30 19:19:51 -04:00
|
|
|
|
"""Structural and Playwright tests for the generated order form."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-07-31 10:15:42 -04:00
|
|
|
|
import base64
|
2026-07-30 19:19:51 -04:00
|
|
|
|
import json
|
|
|
|
|
|
import re
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
2026-08-03 13:51:12 -04:00
|
|
|
|
from types import SimpleNamespace
|
2026-07-30 19:19:51 -04:00
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
2026-08-03 13:51:12 -04:00
|
|
|
|
import generate_form as generate_form_module # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
generate_form = generate_form_module.generate_form
|
2026-07-30 19:19:51 -04:00
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 13:51:12 -04:00
|
|
|
|
def _render(*, google: bool = False) -> str:
|
2026-07-30 19:19:51 -04:00
|
|
|
|
menu, config = _load_fixtures()
|
|
|
|
|
|
return generate_form(
|
|
|
|
|
|
menu,
|
|
|
|
|
|
config,
|
2026-08-03 13:51:12 -04:00
|
|
|
|
api_url="https://api.example.com" if google else "",
|
2026-07-30 19:19:51 -04:00
|
|
|
|
bulk_discount=10,
|
|
|
|
|
|
company_subsidy=50,
|
|
|
|
|
|
google_client_id="google-client.apps.googleusercontent.com" if google else "",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 13:51:12 -04:00
|
|
|
|
def _render_for_browser(*, google: bool = False) -> str:
|
|
|
|
|
|
html = _render(google=google)
|
|
|
|
|
|
if not google:
|
|
|
|
|
|
html = html.replace("<head>", '<head><base href="https://api.example.com/">', 1)
|
|
|
|
|
|
return html
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 10:15:42 -04:00
|
|
|
|
def _google_credential() -> str:
|
|
|
|
|
|
payload = json.dumps(
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "Test Admin",
|
|
|
|
|
|
"email": "test-admin@example.com",
|
|
|
|
|
|
"picture": (
|
|
|
|
|
|
"data:image/svg+xml,"
|
|
|
|
|
|
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
).encode()
|
|
|
|
|
|
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
|
|
|
|
|
return f"e30.{encoded}.signature"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 10:15:42 -04:00
|
|
|
|
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"}',
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 19:19:51 -04:00
|
|
|
|
class TestGenerateFormStructural:
|
2026-08-03 13:51:12 -04:00
|
|
|
|
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):
|
|
|
|
|
|
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
|
2026-09-21 19:34:24 +00:00
|
|
|
|
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
|
|
|
|
|
|
for text in (workflow, http_api):
|
2026-08-03 13:51:12 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
def test_submit_route_has_explicit_throttling(self):
|
2026-09-21 19:34:24 +00:00
|
|
|
|
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
|
|
|
|
|
|
assert "SUBMIT_RATE_PER_SEC = 5.0" in http_api
|
|
|
|
|
|
assert "def _allow_submit()" in http_api
|
|
|
|
|
|
|
|
|
|
|
|
def test_weekly_menu_uses_hmac_publish_without_dynamodb(self):
|
2026-08-03 14:27:59 -04:00
|
|
|
|
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
|
|
|
|
|
|
script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text()
|
2026-09-21 19:34:24 +00:00
|
|
|
|
locals_tf = (REPO_ROOT / "terraform" / "locals.tf").read_text()
|
2026-08-03 14:27:59 -04:00
|
|
|
|
|
|
|
|
|
|
for route in ("/api/publish/settings", "/api/publish/menu"):
|
|
|
|
|
|
assert route in script
|
2026-09-21 19:34:24 +00:00
|
|
|
|
assert 'authorizer = "HMAC"' in locals_tf
|
|
|
|
|
|
assert "X-Meals-Publish-Key" in script
|
2026-08-03 14:27:59 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-30 19:19:51 -04:00
|
|
|
|
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 '<link rel="stylesheet"' not in html
|
|
|
|
|
|
# Only allowed external script is GSI in google mode
|
|
|
|
|
|
local = _render(google=False)
|
|
|
|
|
|
assert "<script src=" not in local
|
|
|
|
|
|
|
2026-07-31 10:15:42 -04:00
|
|
|
|
def test_mobile_viewport_and_safe_area_clearance_are_preserved(self):
|
|
|
|
|
|
html = _render()
|
|
|
|
|
|
css = (SERVER_DIR / "templates" / "form.css").read_text()
|
|
|
|
|
|
assert (
|
|
|
|
|
|
'content="width=device-width, initial-scale=1.0, viewport-fit=cover"'
|
|
|
|
|
|
in html
|
|
|
|
|
|
)
|
|
|
|
|
|
assert "--sticky-footer-height: 80px;" in css
|
|
|
|
|
|
assert (
|
|
|
|
|
|
"padding-bottom: calc(var(--sticky-footer-height) + "
|
|
|
|
|
|
"env(safe-area-inset-bottom, 0px));"
|
|
|
|
|
|
) in css
|
|
|
|
|
|
assert (
|
|
|
|
|
|
"padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));"
|
|
|
|
|
|
) in css
|
|
|
|
|
|
assert (
|
|
|
|
|
|
"min-height: calc(var(--sticky-footer-height) + "
|
|
|
|
|
|
"env(safe-area-inset-bottom, 0px));"
|
|
|
|
|
|
) in css
|
|
|
|
|
|
|
2026-07-30 19:19:51 -04:00
|
|
|
|
def test_script_order_form_then_admin(self):
|
|
|
|
|
|
html = _render()
|
|
|
|
|
|
form_pos = html.index("FORM_JS_MARKER")
|
|
|
|
|
|
admin_pos = html.index("ADMIN_JS_MARKER")
|
|
|
|
|
|
assert form_pos < admin_pos
|
|
|
|
|
|
|
|
|
|
|
|
def test_single_submit_order_definition(self):
|
|
|
|
|
|
html = _render(google=True)
|
|
|
|
|
|
# One async function submitOrder in the inlined form.js
|
|
|
|
|
|
assert len(re.findall(r"async function submitOrder\s*\(", html)) == 1
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_onclick_admin_data_embedding(self):
|
|
|
|
|
|
html = _render(google=True)
|
|
|
|
|
|
assert 'onclick="admin' not in html
|
|
|
|
|
|
# Static markup must not interpolate runtime data into onclick
|
|
|
|
|
|
assert not re.search(r'onclick="[^"]*\$\{', html)
|
|
|
|
|
|
|
2026-07-31 10:15:43 -04:00
|
|
|
|
def test_mobile_admin_card_structure_and_breakpoint(self):
|
|
|
|
|
|
html = _render()
|
|
|
|
|
|
css = (SERVER_DIR / "templates" / "form.css").read_text()
|
|
|
|
|
|
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
|
|
|
|
|
|
|
|
|
|
|
|
assert 'id="admin-cards"' in html
|
|
|
|
|
|
assert 'id="admin-status-alert"' in html
|
|
|
|
|
|
assert 'id="admin-status-info"' in html
|
|
|
|
|
|
assert "@media (max-width: 767px)" in css
|
|
|
|
|
|
assert ".admin-table-wrap { display: none; }" in css
|
|
|
|
|
|
assert "buildAdminCard" in admin_js
|
|
|
|
|
|
assert "dataset.adminAction" in admin_js
|
|
|
|
|
|
assert "confirm(" in admin_js
|
|
|
|
|
|
|
2026-07-30 19:19:51 -04:00
|
|
|
|
def test_script_breakout_meal_escaped(self):
|
|
|
|
|
|
html = _render()
|
|
|
|
|
|
assert "</script><script>alert(1)</script>" 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 </script> inside the CONFIG script would terminate early.
|
|
|
|
|
|
assert "</script>" not in blob
|
|
|
|
|
|
# Parsed JSON still contains the hostile meal name.
|
|
|
|
|
|
cfg = json.loads(blob)
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
m["name"] == "</script><script>alert(1)</script>" 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 "</script><script>alert(1)</script>" in names
|
|
|
|
|
|
assert "<!--<script>alert(1)</script>-->" 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
|
|
|
|
|
|
|
fix(form): accessibility for qty, status, and descriptions (#81)
* fix(form): add status regions, live total, and a11y CSS
Dual alert/status slots for form and admin, aria-live on the sticky
total, success heading focus target, More/Less affordance styles, and
44px coarse-pointer chip padding.
* fix(form): wire a11y labels, status helper, and desc expand
Meal-scoped qty labels at card create time, aria-pressed filter chips,
dual-node showStatus replacing all alert() calls (confirm retained),
overflow-gated More/Less, and success-heading focus after submit.
* test(form): cover a11y labels, status region, and desc toggle
Structural checks for dual status nodes and no alert(); Playwright for
init-time qty labels, aria-pressed chips, overflow More/Less, and
failed-submit text landing in role=alert.
* fix: address review comments
2026-07-31 10:15:43 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-30 19:19:51 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-08-03 13:51:12 -04:00
|
|
|
|
html = _render_for_browser(google=False)
|
2026-07-30 19:19:51 -04:00
|
|
|
|
# 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()
|
2026-07-31 10:15:42 -04:00
|
|
|
|
_mock_form_routes(page)
|
2026-07-30 19:19:51 -04:00
|
|
|
|
page.goto(out.as_uri())
|
|
|
|
|
|
page.wait_for_selector('[data-meal-index="0"]')
|
|
|
|
|
|
yield page
|
|
|
|
|
|
finally:
|
|
|
|
|
|
browser.close()
|
|
|
|
|
|
|
2026-07-31 10:15:42 -04:00
|
|
|
|
@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"
|
2026-08-03 13:51:12 -04:00
|
|
|
|
html_path.write_text(_render_for_browser(google=google))
|
2026-07-31 10:15:42 -04:00
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-07-30 19:19:51 -04:00
|
|
|
|
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"
|
|
|
|
|
|
|
fix(form): accessibility for qty, status, and descriptions (#81)
* fix(form): add status regions, live total, and a11y CSS
Dual alert/status slots for form and admin, aria-live on the sticky
total, success heading focus target, More/Less affordance styles, and
44px coarse-pointer chip padding.
* fix(form): wire a11y labels, status helper, and desc expand
Meal-scoped qty labels at card create time, aria-pressed filter chips,
dual-node showStatus replacing all alert() calls (confirm retained),
overflow-gated More/Less, and success-heading focus after submit.
* test(form): cover a11y labels, status region, and desc toggle
Structural checks for dual status nodes and no alert(); Playwright for
init-time qty labels, aria-pressed chips, overflow More/Less, and
failed-submit text landing in role=alert.
* fix: address review comments
2026-07-31 10:15:43 -04:00
|
|
|
|
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**")
|
|
|
|
|
|
|
2026-07-30 19:19:51 -04:00
|
|
|
|
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"
|
2026-07-31 10:15:42 -04:00
|
|
|
|
|
|
|
|
|
|
@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"]
|
|
|
|
|
|
|
2026-07-31 10:15:43 -04:00
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def mobile_admin_page(self, tmp_path, browser_page):
|
2026-08-03 13:51:12 -04:00
|
|
|
|
html = _render_for_browser(google=False)
|
2026-07-31 10:15:43 -04:00
|
|
|
|
out = tmp_path / "admin-order-form.html"
|
|
|
|
|
|
out.write_text(html)
|
|
|
|
|
|
hostile_name = """O'Reilly "<b>Ops</b>\""""
|
|
|
|
|
|
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,
|
|
|
|
|
|
"</script><script>alert(1)</script>": 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()
|
2026-08-03 14:21:10 -04:00
|
|
|
|
submitted_time = card.locator(".admin-card-time")
|
|
|
|
|
|
assert submitted_time.is_visible()
|
|
|
|
|
|
assert submitted_time.inner_text() == "Jul 30, 10:15 AM"
|
2026-07-31 10:15:43 -04:00
|
|
|
|
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()
|
2026-08-03 14:21:10 -04:00
|
|
|
|
saved_card = page.locator(
|
|
|
|
|
|
".admin-order-card",
|
|
|
|
|
|
has_text="Luis' Lomo Saltado ×2", # noqa: RUF001
|
2026-07-31 10:15:43 -04:00
|
|
|
|
)
|
2026-08-03 14:21:10 -04:00
|
|
|
|
saved_card.wait_for(state="visible")
|
|
|
|
|
|
assert "Luis' Lomo Saltado ×2" in saved_card.inner_text() # noqa: RUF001
|
2026-07-31 10:15:43 -04:00
|
|
|
|
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"] == []
|
|
|
|
|
|
|
2026-07-31 10:15:42 -04:00
|
|
|
|
|
|
|
|
|
|
class TestGenerateFormGooglePlaywright:
|
2026-08-03 16:19:46 -04:00
|
|
|
|
def test_desc_toggle_attaches_after_auth_reveal(self, tmp_path):
|
|
|
|
|
|
sync_api = pytest.importorskip("playwright.sync_api")
|
|
|
|
|
|
sync_playwright = sync_api.sync_playwright
|
|
|
|
|
|
|
|
|
|
|
|
out = tmp_path / "google-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(),
|
|
|
|
|
|
)
|
|
|
|
|
|
_mock_form_routes(page)
|
|
|
|
|
|
page.goto(out.as_uri(), wait_until="domcontentloaded")
|
|
|
|
|
|
|
|
|
|
|
|
short_card = page.locator('[data-meal-index="0"]')
|
|
|
|
|
|
long_card = page.locator('[data-meal-index="1"]')
|
|
|
|
|
|
long_card.wait_for(state="attached")
|
|
|
|
|
|
page.evaluate(
|
|
|
|
|
|
"""() => new Promise((resolve) => {
|
|
|
|
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
|
|
|
|
})"""
|
|
|
|
|
|
)
|
|
|
|
|
|
assert "is-hidden" in (
|
|
|
|
|
|
page.locator("#app").get_attribute("class") or ""
|
|
|
|
|
|
)
|
|
|
|
|
|
assert long_card.locator(".meal-desc-toggle").count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
page.evaluate(
|
|
|
|
|
|
"(credential) => handleCredentialResponse({ credential })",
|
|
|
|
|
|
_google_credential(),
|
|
|
|
|
|
)
|
|
|
|
|
|
toggle = long_card.locator(".meal-desc-toggle")
|
|
|
|
|
|
toggle.wait_for(state="visible")
|
|
|
|
|
|
|
|
|
|
|
|
assert short_card.locator(".meal-desc-toggle").count() == 0
|
|
|
|
|
|
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 ""
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
page.evaluate("signOut()")
|
|
|
|
|
|
assert "is-hidden" in (
|
|
|
|
|
|
page.locator("#app").get_attribute("class") or ""
|
|
|
|
|
|
)
|
|
|
|
|
|
page.evaluate(
|
|
|
|
|
|
"(credential) => handleCredentialResponse({ credential })",
|
|
|
|
|
|
_google_credential(),
|
|
|
|
|
|
)
|
|
|
|
|
|
page.wait_for_function(
|
|
|
|
|
|
"""() => {
|
|
|
|
|
|
const card = document.querySelector('[data-meal-index="1"]');
|
|
|
|
|
|
const desc = card?.querySelector('.meal-desc');
|
|
|
|
|
|
const toggle = card?.querySelector('.meal-desc-toggle');
|
|
|
|
|
|
return !desc?.classList.contains('is-expanded')
|
|
|
|
|
|
&& toggle?.textContent === 'More'
|
|
|
|
|
|
&& toggle?.getAttribute('aria-expanded') === 'false';
|
|
|
|
|
|
}"""
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
toggle = long_card.locator(".meal-desc-toggle")
|
|
|
|
|
|
toggle.click()
|
|
|
|
|
|
assert toggle.inner_text() == "Less"
|
|
|
|
|
|
assert toggle.get_attribute("aria-expanded") == "true"
|
|
|
|
|
|
toggle.click()
|
|
|
|
|
|
assert toggle.inner_text() == "More"
|
|
|
|
|
|
assert toggle.get_attribute("aria-expanded") == "false"
|
|
|
|
|
|
assert "is-expanded" not in (
|
|
|
|
|
|
long_card.locator(".meal-desc").get_attribute("class") or ""
|
|
|
|
|
|
)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
browser.close()
|
|
|
|
|
|
|
2026-07-31 10:15:42 -04:00
|
|
|
|
@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"
|
2026-08-03 13:51:12 -04:00
|
|
|
|
out.write_text(_render_for_browser(google=True))
|
2026-07-31 10:15:42 -04:00
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
)
|