Compare commits

..

21 commits

Author SHA1 Message Date
Adam Moussa
11c7e14ce3
Merge d5a54b2e78 into 9a96babcc6 2026-07-31 00:40:43 +00:00
d5a54b2e78
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.
2026-07-30 20:39:12 -04:00
9f34cb093f
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.
2026-07-30 20:39:12 -04:00
c0f6316f3f
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.
2026-07-30 20:38:59 -04:00
Adam Moussa
9a96babcc6
Merge 5ba3f00157 into ac49e62707 2026-07-30 20:29:47 -04:00
5ba3f00157
test(form): stub admin lookup in browser tests 2026-07-30 20:29:43 -04:00
91286fc922
fix(form): guard stale admin edit responses 2026-07-30 20:27:19 -04:00
e60b8e26dd fix(form): address review findings 2026-07-30 20:25:37 -04:00
dea398626f test(form): cover sticky footer at narrow widths 2026-07-30 20:25:37 -04:00
Adam Moussa
ac49e62707
Merge 0b60d4db55 into 216618a862 2026-07-31 00:22:53 +00:00
0b60d4db55
fix(tests): restore class fixture discovery 2026-07-30 20:22:48 -04:00
Adam Moussa
bb1107ad7f
fix: add @classmethod and use cls in tests/test_generate_form.py
Co-authored-by: seahaven-openswe[bot] <296972425+seahaven-openswe[bot]@users.noreply.github.com>
2026-07-30 20:12:56 -04:00
2df66fd36f
test(form): strengthen phone-width coverage 2026-07-30 19:49:47 -04:00
fca2ec6044
test(form): guarantee Playwright browser cleanup 2026-07-30 19:35:30 -04:00
84cb6251fe
style(tests): apply ruff formatting 2026-07-30 19:28:07 -04:00
58be9ea2f6
fix(form): preserve 480px user bar boundary
Keep the mobile layout below 480px and lock both sides of the breakpoint with browser coverage.
2026-07-30 19:22:59 -04:00
ae03a227d5
fix(form): wrap Google user bar at phone widths
Allow the signed-in Google identity and admin controls to wrap below 480px while preserving the desktop row, with generated-form Playwright coverage for phone widths.
2026-07-30 19:22:28 -04:00
Adam Moussa
216618a862
refactor(form): extract Jinja templates and lock form JS in CI (#77)
Some checks are pending
Deploy / deploy (push) Waiting to run
* refactor(form): extract Jinja templates and lock form JS in CI

Split the monolithic generate_form f-string into form.html.j2/css/js
plus admin.js, inject a single window.CONFIG blob, and add structural
plus Playwright coverage so qty delegation and clamp stay green in CI.

* Update src/server/generate_form.py

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix(form): isolate admin script bindings

* fix(form): address admin and form review findings

* fix(form): resolve remaining review nitpicks

* ci(workflow): restore required check context

Keep the reusable workflow caller job compatible with the organization-required ci / ci status check.

* fix(form): address remaining review findings

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-07-30 19:19:51 -04:00
Adam Moussa
3fe6e74557
Merge pull request #75 from Sea-Haven-Industries/fix/form-mobile-layout
Some checks are pending
Deploy / deploy (push) Waiting to run
fix(form): improve mobile layout for order form and admin toolbar
2026-07-30 16:26:41 -04:00
043eea1443
fix(form): use single-column layout for meals without images on mobile 2026-07-30 11:18:21 -04:00
2a985c1940
fix(form): improve mobile layout for order form and admin toolbar
Stop phone-width sideways scroll from the admin toolbar, stack meal cards
and bump touch targets on coarse pointers, and respect safe-area insets.
2026-07-29 19:19:55 -04:00
8 changed files with 506 additions and 123 deletions

View file

@ -7,7 +7,7 @@ permissions:
contents: read
jobs:
lint:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2
with:
run-tests: false
@ -16,9 +16,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.x
with:
persist-credentials: false
- uses: actions/setup-python@v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.x
with:
python-version: "3.12"

View file

@ -97,9 +97,9 @@ def generate_form(
return template.render(
config=form_config,
config_json=_json_for_script(form_config),
css=(TEMPLATES_DIR / "form.css").read_text(),
form_js=(TEMPLATES_DIR / "form.js").read_text(),
admin_js=(TEMPLATES_DIR / "admin.js").read_text(),
css=(TEMPLATES_DIR / "form.css").read_text(encoding="utf-8"),
form_js=(TEMPLATES_DIR / "form.js").read_text(encoding="utf-8"),
admin_js=(TEMPLATES_DIR / "admin.js").read_text(encoding="utf-8"),
week=week,
scraped_at=scraped_at,
deadline=deadline,

View file

@ -4,11 +4,13 @@ const ADMIN_URL = window.ADMIN_URL;
const ADMIN_PDF_URL = window.ADMIN_PDF_URL;
const MEALS = window.MEALS;
const WEEK = window.WEEK;
const ADMIN_REQUEST_TIMEOUT_MS = 10000;
let currentAdminWeek = '';
let editingOrder = null;
let editQuantities = {};
let lastAdminData = null;
let latestAdminRequest = 0;
function adminHeaders() {
const h = {};
@ -17,8 +19,22 @@ function adminHeaders() {
return h;
}
function adminFetch(url, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ADMIN_REQUEST_TIMEOUT_MS);
return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(timeout));
}
function setAdminActionDisabled(action, disabled) {
document.querySelectorAll('[data-admin-action="' + action + '"]').forEach(btn => {
btn.disabled = disabled;
});
}
function loadWeekOrders(week) {
if (!week) return;
const requestId = ++latestAdminRequest;
currentAdminWeek = week;
editingOrder = null;
editQuantities = {};
@ -27,11 +43,20 @@ function loadWeekOrders(week) {
document.getElementById('admin-empty').classList.add('is-hidden');
document.getElementById('admin-stats').textContent = '';
fetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
.then(r => r.json())
.then(data => renderAdminTable(data))
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(data => {
if (requestId === latestAdminRequest && week === currentAdminWeek) {
renderAdminTable(data);
}
})
.catch(() => {
tbody.innerHTML = '<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
if (requestId === latestAdminRequest && week === currentAdminWeek) {
tbody.innerHTML = '<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
}
});
}
@ -81,8 +106,9 @@ function buildAddItemSelect(availableMeals) {
availableMeals.forEach(m => {
const opt = document.createElement('option');
const price = m.price || 0;
opt.value = m.name;
opt.textContent = m.name + ' ($' + m.price.toFixed(2) + ')';
opt.textContent = m.name + ' ($' + price.toFixed(2) + ')';
select.appendChild(opt);
});
@ -115,10 +141,10 @@ function buildEditActions(email) {
return wrap;
}
function buildViewActions(idx, email, name) {
function buildViewActions(email, name) {
const wrap = document.createElement('div');
wrap.className = 'admin-actions';
const editBtn = buildActionButton('Edit', 'start-edit', { index: String(idx), email });
const editBtn = buildActionButton('Edit', 'start-edit', { email });
editBtn.className = 'btn btn-sm btn-edit';
const deleteBtn = buildActionButton('Delete', 'delete', { email, name });
deleteBtn.className = 'btn btn-sm btn-delete';
@ -190,7 +216,7 @@ function renderAdminTable(data) {
MEALS.forEach(m => { menuPrices[m.name] = m.price; });
const newTotal = editNames.reduce((sum, name) => {
const price = menuPrices[name] || 0;
return sum + price * (editQuantities[name] || 0);
return sum + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0);
}, 0);
totalHtml = '$' + newTotal.toFixed(2);
tr.appendChild(empTd);
@ -229,7 +255,7 @@ function renderAdminTable(data) {
if (isEditing) {
actionsTd.appendChild(buildEditActions(order.employee_email));
} else {
actionsTd.appendChild(buildViewActions(idx, order.employee_email, order.employee_name));
actionsTd.appendChild(buildViewActions(order.employee_email, order.employee_name));
}
tr.appendChild(totalTd);
@ -244,7 +270,7 @@ function renderAdminTable(data) {
const t1 = document.createElement('td');
t1.textContent = 'Total';
const t2 = document.createElement('td');
t2.textContent = mealCount + ' meals';
t2.textContent = mealCount + (mealCount === 1 ? ' meal' : ' meals');
const t3 = document.createElement('td');
t3.className = 'total-cell';
t3.textContent = '$' + data.grand_total.toFixed(2);
@ -267,7 +293,8 @@ function downloadOrderList() {
const names = Object.keys(totals).sort((a, b) => a.localeCompare(b));
const esc = v => {
const s = String(v);
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
const safe = /^[=+\-@\t\r]/.test(s) ? "'" + s : s;
return /[",\n]/.test(safe) ? '"' + safe.replace(/"/g, '""') + '"' : safe;
};
const rows = [['Item', 'Quantity']].concat(names.map(n => [n, totals[n]]));
const csv = rows.map(r => r.map(esc).join(',')).join('\r\n');
@ -288,7 +315,7 @@ function downloadSummaryPdf() {
const btn = document.getElementById('admin-pdf-btn');
if (btn) btn.disabled = true;
window.FormUtils.clearStatus('admin');
fetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
adminFetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
.then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data })))
.then(({ ok, status, data }) => {
if (ok && data.url) {
@ -322,10 +349,17 @@ function downloadSummaryPdf() {
.finally(() => { if (btn) btn.disabled = false; });
}
function adminStartEdit(idx, email) {
fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() })
.then(r => r.json())
function adminStartEdit(email) {
window.FormUtils.clearStatus('admin');
const requestedWeek = currentAdminWeek;
setAdminActionDisabled('start-edit', true);
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(requestedWeek), { headers: adminHeaders() })
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(data => {
if (currentAdminWeek !== requestedWeek) return;
lastAdminData = data;
const order = data.orders.find(o => o.employee_email === email);
if (!order) return;
@ -333,7 +367,14 @@ function adminStartEdit(idx, email) {
editQuantities = {};
order.items.forEach(i => { editQuantities[i.name] = i.quantity; });
renderAdminTable(data);
});
})
.catch(() => {
window.FormUtils.showStatus('Failed to load this order for editing.', {
tone: 'error',
scope: 'admin',
});
})
.finally(() => setAdminActionDisabled('start-edit', false));
}
function adminCancelEdit() {
@ -366,7 +407,8 @@ function adminSaveEdit(email) {
return;
}
window.FormUtils.clearStatus('admin');
fetch(ADMIN_URL, {
setAdminActionDisabled('save-edit', true);
adminFetch(ADMIN_URL, {
method: 'PUT',
headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ week: currentAdminWeek, email, items }),
@ -378,7 +420,8 @@ function adminSaveEdit(email) {
tone: 'error',
scope: 'admin',
});
});
})
.finally(() => setAdminActionDisabled('save-edit', false));
}
function adminDeleteOrder(email, name) {
@ -386,7 +429,8 @@ function adminDeleteOrder(email, name) {
// confirm() retained deliberately for destructive delete guard.
if (!confirm('Delete order for ' + label + '?')) return;
window.FormUtils.clearStatus('admin');
fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), {
setAdminActionDisabled('delete', true);
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), {
method: 'DELETE',
headers: adminHeaders(),
})
@ -397,7 +441,8 @@ function adminDeleteOrder(email, name) {
tone: 'error',
scope: 'admin',
});
});
})
.finally(() => setAdminActionDisabled('delete', false));
}
function handleAdminPanelClick(e) {
@ -405,7 +450,7 @@ function handleAdminPanelClick(e) {
if (btn) {
const action = btn.dataset.adminAction;
if (action === 'start-edit') {
adminStartEdit(parseInt(btn.dataset.index, 10), btn.dataset.email);
adminStartEdit(btn.dataset.email);
} else if (action === 'delete') {
adminDeleteOrder(btn.dataset.email, btn.dataset.name || '');
} else if (action === 'save-edit') {

View file

@ -6,6 +6,7 @@
--radius-card: 12px;
--radius-control: 8px;
--shadow-card: 0 1px 3px rgba(0,0,0,0.08);
--sticky-footer-height: 80px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
@ -14,7 +15,7 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(var(--sticky-footer-height) + env(safe-area-inset-bottom, 0px));
}
.is-hidden { display: none !important; }
@ -75,7 +76,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
margin-bottom: 12px;
}
.user-info-row { display: flex; align-items: center; gap: 12px; }
.user-bar { display: flex; align-items: center; gap: 12px; }
.user-avatar {
width: 40px;
@ -87,9 +88,13 @@ header p { font-size: 0.85rem; opacity: 0.8; }
.user-avatar:not(.is-hidden) { display: block; }
.user-meta { flex: 1; }
.user-meta { flex: 1; min-width: 0; }
.user-name { font-weight: 600; font-size: 0.95rem; }
.user-email { font-size: 0.85rem; color: var(--color-muted); }
.user-email {
font-size: 0.85rem;
color: var(--color-muted);
overflow-wrap: anywhere;
}
.count-display { font-size: 0.8rem; color: var(--color-muted); margin-left: 8px; }
.g-signin-wrap { display: flex; justify-content: center; }
.admin-muted { font-size: 0.8rem; color: #9ca3af; }
@ -212,6 +217,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
background: #fff;
border-top: 1px solid #e5e7eb;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
min-height: calc(var(--sticky-footer-height) + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -2px 8px rgba(0,0,0,0.1);
z-index: 100;
}
@ -360,10 +366,16 @@ header p { font-size: 0.85rem; opacity: 0.8; }
font-size: 1rem;
}
@media (max-width: 479px) {
.user-bar { flex-wrap: wrap; }
.user-meta { flex-basis: calc(100% - 52px); }
.user-bar #admin-btn { margin-left: 52px; }
}
@media (max-width: 480px) {
.meal-card {
display: grid;
grid-template-columns: 80px minmax(0, 1fr);
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
}
.qty-control {
@ -527,7 +539,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
font-size: 0.9rem;
}
.admin-toolbar .back-btn { margin: 0; padding: 8px 16px; font-size: 0.85rem; margin-top: 0; }
.admin-toolbar .back-btn { margin: 0; padding: 8px 16px; font-size: 0.85rem; }
@media (max-width: 600px) {
.admin-toolbar { width: 100%; }

View file

@ -51,7 +51,7 @@
{% if config.authMode == 'google' %}
<div class="employee-info">
<div id="user-info">
<div class="user-info-row">
<div class="user-bar">
<img id="user-avatar" class="user-avatar is-hidden" alt="">
<div class="user-meta">
<div id="user-name" class="user-name"></div>
@ -70,7 +70,7 @@
</div>
{% endif %}
<input type="text" class="search-bar" id="search" placeholder="Search meals...">
<input type="text" class="search-bar" id="search" placeholder="Search meals..." aria-label="Search meals">
<div class="filters" id="filters"></div>

View file

@ -26,19 +26,12 @@ let adminWeeks = [];
let googleCredential = null;
let googleUser = null;
let reopenAtMs = null;
let countdownTimer = null;
let mealsListDelegationBound = false;
window.googleCredential = null;
const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
window.googleCredential = null;
function employeePrice(retail) {
if (!retail) return 0;
@ -72,7 +65,7 @@ function showStatus(message, options) {
}
}
window.FormUtils = { escapeHtml, employeePrice, showStatus, clearStatus };
window.FormUtils = { employeePrice, showStatus, clearStatus };
function syncGoogleCredential() {
window.googleCredential = googleCredential;
@ -86,11 +79,17 @@ function buildItems() {
}));
}
function waitForGoogleAuth() {
function waitForGoogleAuth(attempt = 0) {
if (typeof google !== 'undefined' && google.accounts && google.accounts.id) {
initGoogleAuth();
} else if (attempt < GOOGLE_AUTH_MAX_ATTEMPTS) {
setTimeout(() => waitForGoogleAuth(attempt + 1), 50);
} else {
setTimeout(waitForGoogleAuth, 50);
const signIn = document.getElementById('g-signin-btn');
if (signIn) {
signIn.textContent = 'Google sign-in failed to load. Refresh the page to try again.';
}
console.error('Google Identity Services failed to load.');
}
}
@ -128,7 +127,9 @@ function handleCredentialResponse(response) {
updateTotal();
checkDuplicateOrder();
checkAdmin();
checkAdmin().then(() => {
if (formClosed && !isAdmin) showClosedOverlay();
});
}
function signOut() {
@ -321,13 +322,17 @@ function updateMealCardQty(index) {
const qty = quantities[index] || 0;
card.classList.toggle('selected', qty > 0);
const qtyInput = card.querySelector('.qty');
if (qtyInput) qtyInput.value = qty;
if (qtyInput && qtyInput !== document.activeElement) qtyInput.value = qty;
}
function setQty(index, value) {
function setQtyState(index, value) {
const next = Math.max(0, parseInt(value, 10) || 0);
if (next === 0) delete quantities[index];
else quantities[index] = next;
}
function setQty(index, value) {
setQtyState(index, value);
updateMealCardQty(index);
updateTotal();
}
@ -350,21 +355,21 @@ function bindMealsListDelegation() {
const delta = parseInt(btn.dataset.qtyDelta, 10);
changeQty(index, delta);
});
list.addEventListener('change', (e) => {
const input = e.target.closest('.qty');
if (!input) return;
const card = input.closest('[data-meal-index]');
if (!card) return;
const index = parseInt(card.dataset.mealIndex, 10);
setQty(index, input.value);
});
list.addEventListener('input', (e) => {
const input = e.target.closest('.qty');
if (!input) return;
const card = input.closest('[data-meal-index]');
if (!card) return;
const index = parseInt(card.dataset.mealIndex, 10);
setQty(index, input.value);
setQtyState(index, input.value);
updateTotal();
});
list.addEventListener('focusout', (e) => {
const input = e.target.closest('.qty');
if (!input) return;
const card = input.closest('[data-meal-index]');
if (!card) return;
updateMealCardQty(parseInt(card.dataset.mealIndex, 10));
});
}
@ -478,21 +483,27 @@ function populateEmpSelect(select, employees) {
}
async function loadRoster() {
const select = document.getElementById('emp-name');
let employees = ROSTER;
try {
const res = await fetch(ROSTER_URL);
const data = await res.json();
populateEmpSelect(select, data.employees || ROSTER);
} catch (e) {
populateEmpSelect(select, ROSTER);
}
const candidate = data.employees;
if (Array.isArray(candidate) && candidate.every(e => typeof e.name === 'string' && typeof e.email === 'string')) {
employees = candidate;
} else {
employees = ROSTER;
}
} catch (e) {}
const select = document.getElementById('emp-name');
populateEmpSelect(select, employees);
}
function showClosedOverlay() {
const overlay = document.getElementById('closed-overlay');
overlay.classList.add('is-visible');
overlay.style.display = 'flex';
document.querySelectorAll('.qty-control button').forEach(b => { b.disabled = true; });
document.querySelectorAll('.qty-control button, .qty-control .qty').forEach(el => { el.disabled = true; });
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) submitBtn.disabled = true;
if (formStatusData) startCountdown(formStatusData);
@ -522,10 +533,10 @@ function adminBypassClosed() {
}
function checkAdmin() {
if (!ADMIN_URL) return;
if (!ADMIN_URL) return Promise.resolve();
const headers = {};
if (googleCredential) headers['Authorization'] = 'Bearer ' + googleCredential;
fetch(ADMIN_URL, { headers })
return fetch(ADMIN_URL, { headers })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data && data.weeks) {
@ -607,7 +618,8 @@ function getEasternOffsetMs(date) {
function startCountdown(data) {
if (data && data.reopen_at) reopenAtMs = data.reopen_at * 1000;
updateCountdown();
setInterval(updateCountdown, 1000);
if (countdownTimer !== null) clearInterval(countdownTimer);
countdownTimer = setInterval(updateCountdown, 1000);
}
function updateCountdown() {
@ -648,8 +660,7 @@ async function checkFormStatus() {
formStatusData = data;
if (isAdmin) {
adminBypassClosed();
} else if (CONFIG.authMode === 'google') {
} else {
} else if (CONFIG.authMode !== 'google' || googleCredential) {
showClosedOverlay();
}
}

View file

@ -1,6 +1,6 @@
{
"scraped_at": "2026-07-27T12:00:00+00:00",
"meal_count": 4,
"meal_count": 5,
"meals": [
{
"name": "Chicken Bowl",
@ -45,6 +45,16 @@
"dietary_tags": [],
"image_url": "",
"is_new": false
},
{
"name": "<!--<script>alert(1)</script>-->",
"price": 9.0,
"calories": "300",
"protein": "10g",
"description": "Hostile script comment",
"dietary_tags": [],
"image_url": "",
"is_new": false
}
]
}

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import base64
import json
import re
import sys
@ -66,6 +67,50 @@ def _render(*, google: bool = False, api_key: str = "test-key") -> str:
)
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"
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_local_and_google_render(self):
local = _render(google=False)
@ -124,6 +169,26 @@ class TestGenerateFormStructural:
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")
@ -138,7 +203,6 @@ class TestGenerateFormStructural:
def test_no_onclick_admin_data_embedding(self):
html = _render(google=True)
assert 'onclick="admin' not in html
assert 'onclick="admin' not in html
# Static markup must not interpolate runtime data into onclick
assert not re.search(r'onclick="[^"]*\$\{', html)
@ -150,7 +214,6 @@ class TestGenerateFormStructural:
blob = cfg_match.group(1)
# Literal </script> inside the CONFIG script would terminate early.
assert "</script>" not in blob
assert "<\\/script><script>alert(1)<\\/script>" in blob
# Parsed JSON still contains the hostile meal name.
cfg = json.loads(blob)
assert any(
@ -163,6 +226,7 @@ class TestGenerateFormStructural:
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()
@ -208,14 +272,10 @@ class TestGenerateFormStructural:
class TestGenerateFormPlaywright:
@pytest.fixture(scope="class")
@pytest.fixture
def browser_page(self, 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
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
@ -231,33 +291,127 @@ class TestGenerateFormPlaywright:
"Chromium is required for form Playwright tests. "
"Run: playwright install --with-deps chromium"
) from exc
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
browser.close()
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(google=google, 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(viewport={"width": width, "height": 800})
page.add_init_script(
"""window.google = {
accounts: {
id: {
initialize() {},
renderButton() {},
disableAutoSelect() {},
},
},
};"""
)
page.route(
"https://accounts.google.com/gsi/client",
lambda route: route.abort(),
)
_mock_form_routes(page)
page.goto(html_path.as_uri(), wait_until="domcontentloaded")
page.wait_for_selector('[data-meal-index="0"]', state="attached")
if google:
page.evaluate(
"(credential) => handleCredentialResponse({ credential })",
_google_credential(),
)
page.wait_for_selector('[data-meal-index="0"]', state="visible")
first_increment = page.locator(
'[data-meal-index="0"] [data-qty-delta="1"]'
)
first_increment.click()
first_increment.click()
page.wait_for_function(
"() => document.getElementById('count-display').textContent === "
"'2 meals'"
)
page.evaluate("() => document.fonts.ready")
total = page.locator("#total-display")
count = page.locator("#count-display")
submit = page.locator("#submit-btn")
assert total.inner_text() == "$10.80"
assert count.inner_text() == "2 meals"
assert total.is_visible()
assert count.is_visible()
assert submit.is_visible()
assert submit.is_enabled()
submit.focus()
assert submit.evaluate(
"(element) => element === document.activeElement"
)
page.evaluate(
"() => window.scrollTo(0, document.documentElement.scrollHeight)"
)
layout = page.evaluate(
"""() => {
const rect = (selector) => {
const box = document.querySelector(selector)
.getBoundingClientRect();
return {
left: box.left,
right: box.right,
top: box.top,
bottom: box.bottom,
};
};
const footer = document.querySelector('#sticky-footer');
return {
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
viewportHeight: window.innerHeight,
footer: {
...rect('#sticky-footer'),
position: getComputedStyle(footer).position,
},
total: rect('#total-display'),
count: rect('#count-display'),
submit: rect('#submit-btn'),
lastCard: rect('#meals-list .meal-card:last-child'),
};
}"""
)
assert layout["scrollWidth"] == layout["clientWidth"]
for selector in ("footer", "total", "count", "submit"):
assert layout[selector]["left"] >= 0
assert layout[selector]["right"] <= layout["clientWidth"]
assert layout["footer"]["position"] == "fixed"
assert layout["footer"]["top"] >= 0
assert layout["footer"]["bottom"] <= layout["viewportHeight"]
assert layout["lastCard"]["bottom"] <= layout["footer"]["top"]
finally:
browser.close()
def test_admin_bundle_loads(self, browser_page):
assert browser_page.evaluate("typeof window.loadWeekOrders") == "function"
@ -406,27 +560,176 @@ class TestGenerateFormPlaywright:
# Reset first card via type-in
qty = page.locator('[data-meal-index="0"] .qty')
qty.fill("-3")
qty.dispatch_event("change")
qty.blur()
page.wait_for_function(
"() => document.querySelector('[data-meal-index=\"0\"] .qty').value === '0'"
)
assert qty.input_value() == "0"
# Total may still include other meals from prior test if class-scoped page
# is shared — set all known cards to 0 then set one meal to 2 via type-in.
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'"
)
qty.fill("2")
qty.dispatch_event("change")
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"]
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"
)