mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 13:33:13 +00:00
* feat(menu): publish the weekly menu from the job worker Monday publish parses the catalog embedded in the Redefine menu page and runs on the Fargate worker, so the GitHub Actions scrape cron can go away. * fix(menu): address review feedback Use the form deadline in the Monday Slack post, and compare that message exactly so CodeQL does not treat the test as URL sanitization.
1273 lines
50 KiB
Python
1273 lines
50 KiB
Python
"""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("<head>", '<head><base href="https://api.example.com/">', 1)
|
||
return html
|
||
|
||
|
||
def _google_credential(extra: dict | None = None) -> str:
|
||
payload_obj = {
|
||
"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'/>"
|
||
),
|
||
}
|
||
if extra:
|
||
payload_obj.update(extra)
|
||
encoded = (
|
||
base64.urlsafe_b64encode(json.dumps(payload_obj).encode()).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):
|
||
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
|
||
publish_job = (
|
||
REPO_ROOT / "src" / "server" / "jobs" / "publish_menu.py"
|
||
).read_text()
|
||
for text in (http_api, publish_job):
|
||
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):
|
||
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_manual_publish_stays_hmac_and_scheduled_job_is_in_process(self):
|
||
script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text()
|
||
locals_tf = (REPO_ROOT / "terraform" / "locals.tf").read_text()
|
||
scheduler = (REPO_ROOT / "terraform" / "scheduler.tf").read_text()
|
||
publish_job = (
|
||
REPO_ROOT / "src" / "server" / "jobs" / "publish_menu.py"
|
||
).read_text()
|
||
|
||
for route in ("/api/publish/settings", "/api/publish/menu"):
|
||
assert route in script
|
||
assert 'authorizer = "HMAC"' in locals_tf
|
||
assert "X-Meals-Publish-Key" in script
|
||
assert 'boto3.resource("dynamodb")' not in script
|
||
assert 'event = "publish_menu"' in scheduler
|
||
assert 'schedule = "cron(30 7 ? * MON *)"' in scheduler
|
||
assert "put_menu" in publish_job
|
||
assert not (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").exists()
|
||
|
||
def test_explicit_week_is_embedded_in_the_form(self):
|
||
menu, config = _load_fixtures()
|
||
html = generate_form(menu, config, week="2026-W38")
|
||
config_blob = _extract_config(html)
|
||
assert config_blob["week"] == "2026-W38"
|
||
assert "/api/form-status/2026-W38" in html
|
||
|
||
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
|
||
|
||
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
|
||
|
||
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)
|
||
|
||
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
|
||
|
||
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
|
||
|
||
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() {},
|
||
prompt() {},
|
||
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 "<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()
|
||
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:
|
||
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() {},
|
||
prompt() {},
|
||
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()
|
||
|
||
def test_refresh_restores_google_session(self, tmp_path):
|
||
sync_api = pytest.importorskip("playwright.sync_api")
|
||
sync_playwright = sync_api.sync_playwright
|
||
|
||
out = tmp_path / "google-session-form.html"
|
||
out.write_text(_render_for_browser(google=True))
|
||
credential = _google_credential({"exp": 2_000_000_000})
|
||
|
||
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() {},
|
||
prompt() {},
|
||
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")
|
||
page.evaluate(
|
||
"(token) => handleCredentialResponse({ credential: token })",
|
||
credential,
|
||
)
|
||
page.wait_for_function(
|
||
"""() => {
|
||
const app = document.getElementById('app');
|
||
return app && !app.classList.contains('is-hidden');
|
||
}"""
|
||
)
|
||
assert page.evaluate(
|
||
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
|
||
)
|
||
|
||
page.reload(wait_until="domcontentloaded")
|
||
_mock_form_routes(page)
|
||
page.wait_for_function(
|
||
"""() => {
|
||
const app = document.getElementById('app');
|
||
const overlay = document.getElementById('auth-overlay');
|
||
const email = document.getElementById('user-email');
|
||
return app && !app.classList.contains('is-hidden')
|
||
&& overlay && overlay.classList.contains('is-hidden')
|
||
&& email && email.textContent === 'test-admin@example.com';
|
||
}"""
|
||
)
|
||
|
||
page.evaluate("signOut()")
|
||
assert (
|
||
page.evaluate(
|
||
"() => sessionStorage.getItem('seahaven.meals.googleIdToken')"
|
||
)
|
||
is None
|
||
)
|
||
page.reload(wait_until="domcontentloaded")
|
||
_mock_form_routes(page)
|
||
page.wait_for_function(
|
||
"""() => {
|
||
const overlay = document.getElementById('auth-overlay');
|
||
const app = document.getElementById('app');
|
||
return overlay && !overlay.classList.contains('is-hidden')
|
||
&& app && app.classList.contains('is-hidden');
|
||
}"""
|
||
)
|
||
|
||
page.evaluate(
|
||
"(token) => sessionStorage.setItem('seahaven.meals.googleIdToken', token)",
|
||
_google_credential({"exp": 1}),
|
||
)
|
||
page.reload(wait_until="domcontentloaded")
|
||
_mock_form_routes(page)
|
||
page.wait_for_function(
|
||
"""() => {
|
||
const overlay = document.getElementById('auth-overlay');
|
||
const app = document.getElementById('app');
|
||
return overlay && !overlay.classList.contains('is-hidden')
|
||
&& app && app.classList.contains('is-hidden')
|
||
&& !sessionStorage.getItem('seahaven.meals.googleIdToken');
|
||
}"""
|
||
)
|
||
finally:
|
||
browser.close()
|
||
|
||
@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() {},
|
||
prompt() {},
|
||
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"
|
||
)
|