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
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
from generate_form import generate_form # noqa: E402
|
|
|
|
|
|
|
|
|
|
EXPECTED_CONFIG_KEYS = {
|
|
|
|
|
"authMode",
|
|
|
|
|
"meals",
|
|
|
|
|
"roster",
|
|
|
|
|
"week",
|
|
|
|
|
"submitUrl",
|
|
|
|
|
"statusUrl",
|
|
|
|
|
"rosterUrl",
|
|
|
|
|
"adminUrl",
|
|
|
|
|
"adminPdfUrl",
|
|
|
|
|
"apiKey",
|
|
|
|
|
"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, api_key: str = "test-key") -> str:
|
|
|
|
|
menu, config = _load_fixtures()
|
|
|
|
|
return generate_form(
|
|
|
|
|
menu,
|
|
|
|
|
config,
|
|
|
|
|
api_url="https://api.example.com",
|
|
|
|
|
api_key=api_key,
|
|
|
|
|
bulk_discount=10,
|
|
|
|
|
company_subsidy=50,
|
|
|
|
|
google_client_id="google-client.apps.googleusercontent.com" if google else "",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
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-30 19:19:51 -04:00
|
|
|
class TestGenerateFormStructural:
|
|
|
|
|
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_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_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_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(google=False, api_key="")
|
|
|
|
|
# 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()
|
|
|
|
|
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.goto(out.as_uri())
|
|
|
|
|
page.wait_for_selector('[data-meal-index="0"]')
|
|
|
|
|
yield page
|
|
|
|
|
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_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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGenerateFormGooglePlaywright:
|
|
|
|
|
@pytest.fixture(scope="class")
|
|
|
|
|
@classmethod
|
|
|
|
|
def signed_in_admin_page(cls, tmp_path_factory):
|
|
|
|
|
try:
|
|
|
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
|
except ImportError as exc: # pragma: no cover
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"playwright package is required; install requirements.txt"
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
out = tmp_path_factory.mktemp("google-form") / "order-form.html"
|
|
|
|
|
out.write_text(_render(google=True, api_key=""))
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|