import { chromium } from 'playwright'; const BASE = 'http://localhost:5173'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // After a successful password submit, the app shows a 2FA screen with the code // displayed in the UI. This helper waits for it, reads the code, and submits. async function complete2FA(page) { const codeEl = page.locator('[class*="code-number"]').first(); try { await codeEl.waitFor({ state: 'visible', timeout: 4000 }); } catch { return; // 2FA screen did not appear — user likely already navigated } const code = await codeEl.innerText().catch(() => ''); if (!code) return; await page.locator('input#code').fill(code.trim()); await page.locator('button[type="submit"]').click(); await sleep(500); } // Reset seed reservations so the test is repeatable across runs. async function resetDb() { const api = 'http://localhost:3001'; const patch = (id, body) => fetch(`${api}/reservations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); // Delete any reservations created by previous test runs (not part of seed r1–r56). const seedIds = new Set(Array.from({ length: 56 }, (_, i) => `r${i + 1}`)); const all = await fetch(`${api}/reservations`).then((r) => r.json()).catch(() => []); await Promise.all( all .filter((r) => !seedIds.has(r.id)) .map((r) => fetch(`${api}/reservations/${r.id}`, { method: 'DELETE' })) ); // Ensure r1 and r2 are pending so admin-confirm test always finds a button. await patch('r1', { status: 'pending' }); await patch('r2', { status: 'pending' }); } async function run() { await resetDb(); const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext(); const page = await ctx.newPage(); // Capture browser console errors const pageErrors = []; page.on('pageerror', (err) => pageErrors.push(err.message)); page.on('console', (msg) => { if (msg.type() === 'error') pageErrors.push(msg.text()); }); const results = []; const ok = (l, n='') => results.push(`PASS ${l}${n ? ' — '+n : ''}`); const fail = (l, n='') => results.push(`FAIL ${l}${n ? ' — '+n : ''}`); const warn = (l, n='') => results.push(`WARN ${l}${n ? ' — '+n : ''}`); // ── 1. Login page loads ──────────────────────────────────────────────────── await page.goto(BASE + '/login'); await page.waitForLoadState('networkidle'); const hasEmailInput = await page.locator('input[type="email"]').isVisible().catch(() => false); hasEmailInput ? ok('Login page loads (email field visible)') : fail('Login page missing email field'); // ── 2. Wrong credentials show error ─────────────────────────────────────── await page.fill('input[type="email"]', 'wrong@example.com'); await page.fill('input[type="password"]', 'wrongpass'); await page.click('button[type="submit"]'); await sleep(1000); const errorVisible = await page.locator('text=/invalid|incorrect|wrong/i').isVisible().catch(() => false); errorVisible ? ok('Wrong credentials → error message') : fail('Wrong credentials — no error shown'); // ── 3. Admin login ──────────────────────────────────────────────────────── await page.fill('input[type="email"]', 'admin@reservations.dev'); await page.fill('input[type="password"]', 'Admin1234!'); await page.click('button[type="submit"]'); await complete2FA(page); await page.waitForURL('**/admin', { timeout: 5000 }).catch(() => {}); page.url().includes('/admin') ? ok('Admin login → /admin') : fail('Admin redirect failed', page.url()); // ── 4. Admin table rows ─────────────────────────────────────────────────── await page.waitForSelector('table tbody tr', { timeout: 5000 }).catch(() => {}); const rowCount = await page.locator('tbody tr').count().catch(() => 0); rowCount > 0 ? ok('Admin table', `${rowCount} rows`) : fail('Admin table empty'); // ── 5. Confirm button on pending row ───────────────────────────────────── const confirmBtn = page.locator('[aria-label^="Confirm reservation"]').first(); const hasConfirm = await confirmBtn.isVisible().catch(() => false); hasConfirm ? ok('Confirm button visible') : fail('Confirm button missing'); if (hasConfirm) { await confirmBtn.click(); await sleep(1200); const confirmedBadge = await page.locator('text=Confirmed').count(); confirmedBadge >= 1 ? ok('Confirm click updates row to Confirmed') : warn('Confirmed badge not found after click'); } // ── 6. Delete modal ─────────────────────────────────────────────────────── // Wait for table to re-render after previous action await page.waitForSelector('table tbody tr', { timeout: 4000 }).catch(() => {}); await sleep(500); const deleteBtn = page.locator('[aria-label^="Delete reservation"]').first(); await deleteBtn.click().catch(() => {}); await sleep(600); const modalVisible = await page.locator('[role="dialog"]').isVisible().catch(() => false); modalVisible ? ok('Delete confirmation modal opens') : fail('Delete modal did not open'); // Dismiss via Cancel button const cancelModalBtn = page.locator('[class*="modal__btn--cancel"]').first(); await cancelModalBtn.click().catch(() => { page.keyboard.press('Escape').catch(() => {}); }); await sleep(300); // ── 7. Calendar tab ─────────────────────────────────────────────────────── const calTab = page.locator('button', { hasText: /Calendar/i }); const hasCalTab = await calTab.isVisible().catch(() => false); hasCalTab ? ok('Calendar tab button visible') : fail('Calendar tab missing'); if (hasCalTab) { await calTab.click(); await sleep(600); const monthTitle = await page.locator('text=/January|February|March|April|May|June|July|August|September|October|November|December/').first().innerText().catch(() => ''); monthTitle ? ok('Calendar view renders month', monthTitle) : fail('Calendar month title missing'); const monCell = await page.locator('text=Mon').first().isVisible().catch(() => false); monCell ? ok('Calendar weekday headers render') : fail('Calendar weekday headers missing'); // Click a day cell that has bookings const busyCell = page.locator('[class*="cell--busy"]').first(); const hasBusy = await busyCell.isVisible().catch(() => false); if (hasBusy) { await busyCell.click(); await sleep(400); const panelVisible = await page.locator('[class*="cal__panel"]').isVisible().catch(() => false); panelVisible ? ok('Clicking busy day opens side panel') : fail('Calendar side panel did not open'); } else { warn('No busy day cells visible in current month view'); } // Navigate to next month await page.locator('[aria-label="Next month"]').click().catch(() => {}); await sleep(400); const newMonth = await page.locator('text=/January|February|March|April|May|June|July|August|September|October|November|December/').first().innerText().catch(() => ''); newMonth !== monthTitle ? ok('Calendar next-month navigation', newMonth) : fail('Calendar did not advance to next month'); } // ── 8. Admin logout → client login ─────────────────────────────────────── await page.locator('button:has-text("Table")').click().catch(() => {}); await sleep(200); await page.click('button:has-text("Sign out")').catch(() => {}); await page.waitForURL('**/login', { timeout: 3000 }).catch(() => {}); page.url().includes('/login') ? ok('Admin logout → /login') : warn('Logout redirect', page.url()); await page.fill('input[type="email"]', 'anna.kowalski@example.com'); await page.fill('input[type="password"]', 'Client1234!'); await page.click('button[type="submit"]'); await complete2FA(page); await page.waitForURL('**/dashboard', { timeout: 5000 }).catch(() => {}); page.url().includes('/dashboard') ? ok('Client login → /dashboard') : fail('Dashboard redirect', page.url()); // ── 9. AvailabilitySearch ───────────────────────────────────────────────── await page.waitForLoadState('networkidle'); await sleep(500); const availTitle = await page.locator('text=Check Availability').isVisible().catch(() => false); availTitle ? ok('AvailabilitySearch section renders') : fail('AvailabilitySearch missing'); // ── 10. URL sync ────────────────────────────────────────────────────────── await page.locator('input[type="date"]').first().fill('2026-07-15'); await page.locator('select').first().selectOption({ index: 1 }); await sleep(1200); const urlAfterFilter = page.url(); urlAfterFilter.includes('date=2026-07-15') ? ok('Date syncs to URL') : fail('Date param missing', urlAfterFilter); urlAfterFilter.includes('service=') ? ok('Service syncs to URL') : fail('Service param missing', urlAfterFilter); // ── 11. Time slot grid ──────────────────────────────────────────────────── const slotGrid = await page.locator('button:has-text("09:00")').isVisible().catch(() => false); slotGrid ? ok('Time slot grid renders (09:00 visible)') : fail('Time slot grid missing'); // ── 12. Slot click pre-fills wizard ────────────────────────────────────── const freeSlot = page.locator('[class*="slot--free"]').first(); const hasFreeSlot = await freeSlot.isVisible().catch(() => false); if (hasFreeSlot) { const slotText = await freeSlot.innerText().catch(() => '?'); await freeSlot.click(); await sleep(600); const step3Active = await page.locator('text=/Special Requirements/i').isVisible().catch(() => false); step3Active ? ok(`Slot click (${slotText}) jumps wizard to step 3`) : fail('Slot click did not advance wizard'); } else { warn('No free slots for selected date/service'); } // ── 13. BookingWizard step navigation ──────────────────────────────────── await page.goto(BASE + '/dashboard'); await page.waitForLoadState('networkidle'); await page.waitForSelector('[class*="wizard__step-title"]', { timeout: 5000 }).catch(() => {}); const wizardTitle = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => ''); wizardTitle.includes('Select a Service') ? ok('Wizard renders at step 1 after reload') : fail('Wizard step 1 title missing', `got: "${wizardTitle}"`); // Pick a service card const serviceCard = page.locator('[class*="service-card"]').first(); const hasCards = await serviceCard.isVisible().catch(() => false); hasCards ? ok('Service cards render') : fail('Service cards missing'); if (hasCards) await serviceCard.click(); // Next → step 2 await page.locator('button:has-text("Next")').first().click().catch(() => {}); await sleep(400); const step2Title = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => ''); step2Title.includes('Date') ? ok('Wizard step 2 (Date & Time)') : fail('Step 2 not reached', step2Title); // Fill date + time for a date with no conflicts await page.locator('input[type="date"]').last().fill('2026-09-01'); await page.locator('input[type="time"]').last().fill('14:30'); await page.locator('button:has-text("Next")').first().click().catch(() => {}); await sleep(400); const step3Title = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => ''); step3Title.includes('Requirements') ? ok('Wizard step 3 (Requirements)') : fail('Step 3 not reached', step3Title); await page.locator('button:has-text("Next")').first().click().catch(() => {}); await sleep(400); const step4Title = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => ''); step4Title.includes('Summary') ? ok('Wizard step 4 (Summary)') : fail('Step 4 not reached', step4Title); // ── 14. Payment modal full flow ─────────────────────────────────────────── const payBtn = page.locator('button:has-text("Confirm & Pay")'); const hasPayBtn = await payBtn.isVisible().catch(() => false); hasPayBtn ? ok('Confirm & Pay button in step 4') : fail('Confirm & Pay button missing'); if (hasPayBtn) { await payBtn.click(); await sleep(500); const payModalVisible = await page.locator('text=/Secure Payment/i').isVisible().catch(() => false); payModalVisible ? ok('Payment modal opens') : fail('Payment modal did not open'); if (payModalVisible) { const depositAmt = await page.locator('[class*="deposit-amt"]').innerText().catch(() => ''); depositAmt ? ok('Deposit amount displayed', depositAmt) : fail('Deposit amount missing'); // Fill card form using placeholder selectors await page.locator('input[placeholder*="1234"]').fill('4111111111111111'); await page.locator('input[placeholder*="MM"]').fill('12/28'); await page.locator('input[type="password"]').fill('123'); await page.locator('input[placeholder*="John"]').fill('Jan Kowalski'); await sleep(300); // Check no validation errors yet const payDepositBtn = page.locator('[class*="pay__pay-btn"]'); await payDepositBtn.click().catch(() => {}); // Wait for PaymentModal to reach 'done' phase (~1.5s) then createReservation POST (~200ms) // then setPaymentOpen(false) causing overlay to disappear await page.locator('[class*="pay__overlay"]').waitFor({ state: 'hidden', timeout: 8000 }).catch(() => {}); await sleep(400); const bookingConfirmed = await page.locator('text=/Booking Confirmed/i').isVisible().catch(() => false); bookingConfirmed ? ok('Full booking flow — Booking Confirmed shown') : fail('Booking Confirmed not shown after payment'); // Check receipt fields appear const receiptVisible = await page.locator('[class*="wizard__receipt"]').isVisible().catch(() => false); receiptVisible ? ok('Receipt block rendered') : warn('Receipt block not visible'); } } // ── 15. My Reservations sidebar ────────────────────────────────────────── // Navigate fresh to see sidebar (not in done state) await page.goto(BASE + '/dashboard'); await page.waitForLoadState('networkidle'); await sleep(600); const myResVisible = await page.locator('text=/My Reservations/i').first().isVisible().catch(() => false); myResVisible ? ok('My Reservations sidebar renders') : fail('My Reservations missing'); // Check it shows at least one reservation for this user (u2 has multiple) const resItems = await page.locator('[class*="my-res__item"]').count().catch(() => 0); resItems > 0 ? ok('My Reservations lists items', `${resItems} items`) : fail('No reservation items found'); // ── 16. Cancel from My Reservations ────────────────────────────────────── const cancelResBtn = page.locator('[class*="my-res__cancel-btn"]').first(); const hasCancelRes = await cancelResBtn.isVisible().catch(() => false); if (hasCancelRes) { const countBefore = await page.locator('[class*="my-res__item"]').count(); await cancelResBtn.click(); await sleep(1000); const cancelledBadge = await page.locator('text=Cancelled').count(); cancelledBadge >= 1 ? ok('Cancel from My Reservations updates status') : warn('Cancelled badge not found after cancel'); } else { warn('No cancellable reservations visible in sidebar'); } // ── 17. Dark mode toggle ───────────────────────────────────────────────── const themeBtn = page.locator('[aria-label*="dark"],[aria-label*="light"],[aria-label*="theme"]').first(); const hasTheme = await themeBtn.isVisible().catch(() => false); if (hasTheme) { await themeBtn.click(); await sleep(300); const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); theme === 'dark' ? ok('Dark mode toggle works') : warn('data-theme after toggle', theme ?? 'null'); } else { warn('Theme toggle not found'); } // ── 18. Protected route (client blocked from /admin) ───────────────────── await page.goto(BASE + '/admin'); await sleep(600); page.url().includes('/dashboard') ? ok('Client blocked from /admin → redirected to /dashboard') : fail('Client could access /admin', page.url()); // ── Errors ──────────────────────────────────────────────────────────────── if (pageErrors.length > 0) { warn('Browser console errors during test', pageErrors.slice(0, 3).join(' | ')); } await browser.close(); console.log('\n' + '═'.repeat(62)); console.log(' MANUAL TEST RESULTS — ' + new Date().toISOString().slice(0,19)); console.log('═'.repeat(62)); results.forEach((r) => console.log(' ' + r)); const passed = results.filter((r) => r.startsWith('PASS')).length; const failed = results.filter((r) => r.startsWith('FAIL')).length; const warned = results.filter((r) => r.startsWith('WARN')).length; console.log('═'.repeat(62)); console.log(` ${passed} passed · ${warned} warnings · ${failed} failed`); console.log('═'.repeat(62)); if (failed > 0) process.exit(1); } run().catch((err) => { console.error(err); process.exit(1); });