#!/usr/bin/env node /** * E2E Booking Flow — irina-vik.ru * Register → Book service → Logout → Login → View appointments * * Environment: * GITEA_ISSUE - Gitea issue to post results (optional) */ const fs = require('fs'); const path = require('path'); const gitea = require('./lib/gitea-client'); const { launchBrowser, newContext, navigateTo, BASE_ARGS } = require('./lib/browser-launcher'); const BASE_URL = 'https://irina-vik.ru'; const SCREENSHOT_DIR = path.join(__dirname, '..', 'visual', 'e2e'); const GITEA_ISSUE = parseInt(process.env.GITEA_ISSUE, 10) || null; const TIMESTAMP = Date.now(); const TEST_EMAIL = `apaw.test.${TIMESTAMP}@mailinator.com`; const TEST_PASSWORD = 'TestPass123!'; const TEST_NAME = 'Тест'; const TEST_LASTNAME = 'Пользователев'; function ensureDir(d) { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); } async function ss(page, name) { const f = path.join(SCREENSHOT_DIR, `e2e-${name}.png`); await page.screenshot({ path: f, fullPage: false }); console.log(` 📸 ${name}`); return f; } async function main() { console.log('═══════════════════════════════════════════════════'); console.log(' E2E: Register → Book → Logout → Login → Cabinet'); console.log('═══════════════════════════════════════════════════\n'); ensureDir(SCREENSHOT_DIR); const steps = []; const browser = await launchBrowser(); const ctx = await newContext(browser); const page = await ctx.newPage(); // ─── STEP 1: Register ───────────────────────────────────── console.log('📍 Step 1: Registration'); try { await navigateTo(page, BASE_URL); await page.waitForTimeout(3000); await ss(page, '01-homepage'); // Open auth modal first (SPA requires clicking "Войти" in header) const authModalOpened = await page.evaluate(() => { const headerLinks = document.querySelectorAll('a, button'); for (const l of headerLinks) { const text = (l.textContent || '').trim(); if (text === 'Войти' || text === 'ВОЙТИ') { l.click(); return text; } } // Also try data attributes const authBtn = document.querySelector('[data-bs-toggle="modal"], [data-toggle="modal"]'); if (authBtn) { authBtn.click(); return 'modal-toggle'; } return null; }); if (authModalOpened) { console.log(` Opened auth modal: "${authModalOpened}"`); await page.waitForTimeout(1500); } // Open registration tab await page.click('a[href="#tab-register"]').catch(() => {}); // Fallback: try evaluating click in JS await page.evaluate(() => { const links = document.querySelectorAll('a'); for (const a of links) { if ((a.textContent || '').trim() === 'Регистрация' || (a.href || '').includes('#tab-register')) { a.click(); break; } } }); await page.waitForTimeout(1500); await ss(page, '02-reg-tab'); // Wait for reg form to appear and scroll modal into view await page.waitForSelector('#reg-name', { state: 'visible', timeout: 5000 }).catch(() => {}); // Force scroll modal into view await page.evaluate(() => { const modal = document.querySelector('.modal.show, .modal-dialog, #authModal'); if (modal) modal.scrollIntoView(); }); await page.waitForTimeout(500); // Fill registration form const regFields = [ { selector: '#reg-name', value: TEST_NAME }, { selector: '#reg-lastname', value: TEST_LASTNAME }, { selector: '#reg-email', value: TEST_EMAIL }, { selector: '#reg-phone', value: '+7 (999) 123-45-67' }, { selector: '#reg-password', value: TEST_PASSWORD }, ]; for (const f of regFields) { try { await page.fill(f.selector, f.value, { timeout: 3000 }); console.log(` Filled: ${f.selector}`); } catch (e) { // Try clicking label first then filling try { await page.click(f.selector, { timeout: 2000 }); await page.waitForTimeout(300); await page.fill(f.selector, f.value, { timeout: 3000 }); console.log(` Filled (after click): ${f.selector}`); } catch (e2) { console.log(` ⚠️ Could not fill: ${f.selector} - ${e2.message.slice(0, 60)}`); } } } await ss(page, '03-reg-filled'); // Submit registration const submitResult = await page.evaluate(() => { const btns = document.querySelectorAll('#tab-register button, #tab-register [type="submit"], .modal button[type="submit"]'); for (const b of btns) { const text = (b.textContent || '').trim().toLowerCase(); if (text.includes('зарегистриров') || text.includes('регистрац') || text.includes('создать')) { b.click(); return text; } } // Fallback: find any submit in modal const modal = document.querySelector('.modal.show'); if (modal) { const btn = modal.querySelector('button:not([class*="close"]):not([class*="back"])'); if (btn) { btn.click(); return btn.textContent.trim().slice(0, 60); } } return null; }); if (submitResult) { console.log(` Clicked: "${submitResult}"`); await page.waitForTimeout(3000); await ss(page, '04-reg-result'); const resultText = await page.evaluate(() => document.body?.innerText?.slice(0, 1000) || ''); if (resultText.includes('подтверд') || resultText.includes('успешн') || resultText.includes('письм') || resultText.includes('отправлен')) { console.log(' ✅ Registration successful'); steps.push({ step: 'register', status: 'PASS' }); } else if (resultText.includes('уже существ') || resultText.includes('занят') || resultText.includes('зарегистрирован')) { console.log(' ⚠️ Email already registered'); steps.push({ step: 'register', status: 'PARTIAL', note: 'email exists' }); } else { console.log(` Result text: ${resultText.slice(0, 200)}`); steps.push({ step: 'register', status: 'DONE', note: 'form submitted' }); } } else { console.log(' ⚠️ No submit button found'); steps.push({ step: 'register', status: 'PARTIAL', note: 'form filled, no submit' }); } } catch (err) { console.log(` ❌ ${err.message.slice(0, 120)}`); steps.push({ step: 'register', status: 'FAIL', error: err.message.slice(0, 80) }); } // ─── STEP 2: Book a service ─────────────────────────────── console.log('\n📍 Step 2: Book a service'); try { // Reload to reset state await navigateTo(page, BASE_URL); await page.waitForTimeout(3000); // Scroll to booking section and click service await page.evaluate(() => { document.getElementById('page-booking')?.scrollIntoView(); // Click first service option const svc = document.querySelector('.service-option'); if (svc) svc.click(); return true; }); await page.waitForTimeout(2000); // Now call selectSvcOpt to properly advance to step 2 const serviceSelected = await page.evaluate(() => { if (typeof selectSvcOpt === 'function') { const opts = document.querySelectorAll('.service-option'); if (opts.length > 0) { const opt = opts[0]; const price = opt.getAttribute('data-price') || '3500'; const id = opt.getAttribute('data-id') || '10'; selectSvcOpt(opt, parseInt(id), opt.querySelector('div > div')?.textContent?.trim() || 'Консультация', parseInt(price), 60); return { clicked: opt.textContent.trim().slice(0, 80) }; } } // Fallback: just click const first = document.querySelector('.service-option'); if (first) { first.click(); return { clicked: first.textContent.trim().slice(0, 80) }; } return null; }); console.log(` Service selected: ${JSON.stringify(serviceSelected)}`); await page.waitForTimeout(2000); await ss(page, '05-booking-step1-service'); // Step 2: Pick a date from the calendar const datePicked = await page.evaluate(() => { // Find available days in calendar const available = document.querySelectorAll('.calendar-day.available, .day.available, td.available, [class*="day"][class*="available"]'); if (available.length > 0) { available[0].click(); return { clicked: available[0].textContent.trim(), count: available.length }; } // Try calendar-day const calDays = document.querySelectorAll('[class*="calendar"] [class*="day"]:not(.disabled):not(.past):not(.empty)'); for (const d of calDays) { const text = d.textContent.trim(); if (text && parseInt(text) > 0) { d.click(); return { clicked: text, count: calDays.length }; } } return null; }); console.log(` Date picked: ${JSON.stringify(datePicked)}`); await page.waitForTimeout(1500); await ss(page, '06-booking-step2-date'); if (datePicked) { // Step 3: Pick a time const timePicked = await page.evaluate(() => { const slots = document.querySelectorAll('[class*="time-slot"], [class*="slot"]:not(.disabled), .time-option, [class*="slot"][class*="available"]'); if (slots.length > 0) { slots[0].click(); return { clicked: slots[0].textContent.trim(), count: slots.length }; } // Try buttons with time const btns = document.querySelectorAll('.booking-body button:not(.btn-booking-back):not(.calendar-nav)'); for (const b of btns) { const text = b.textContent.trim(); if (text.match(/\d{1,2}:\d{2}/)) { b.click(); return { clicked: text, count: btns.length }; } } return null; }); console.log(` Time picked: ${JSON.stringify(timePicked)}`); await page.waitForTimeout(1000); await ss(page, '07-booking-step3-time'); // Step 4: Fill personal data and submit const dataFilled = await page.evaluate(() => { const nameInput = document.querySelector('#b-name, [name*="name"], .booking-body input[placeholder*="Имя"]'); const emailInput = document.querySelector('#b-email, [name*="email"], .booking-body input[placeholder*="email"]'); const phoneInput = document.querySelector('#b-phone, [name*="phone"], .booking-body input[placeholder*="телефон"]'); return { hasName: !!nameInput, hasEmail: !!emailInput, hasPhone: !!phoneInput, }; }); console.log(` Data form fields: ${JSON.stringify(dataFilled)}`); if (dataFilled.hasName || dataFilled.hasEmail) { // Since we already registered/logged in, this might be auto-filled or have "login" button const submitted = await page.evaluate(() => { // Look for submit/confirm button const btns = document.querySelectorAll('.booking-body .btn-booking-next, .booking-body button[type="submit"], .booking-body button'); for (const b of btns) { const text = b.textContent.trim().toLowerCase(); if (text.includes('отправ') || text.includes('подтверд') || text.includes('записать') || text.includes('далее') || text.includes('войти')) { b.click(); return text.slice(0, 60); } } return null; }); console.log(` Clicked: "${submitted}"`); await page.waitForTimeout(3000); } await ss(page, '08-booking-result'); steps.push({ step: 'booking', status: 'PASS', service: serviceSelected?.clicked, date: datePicked?.clicked }); } else { console.log(' ⚠️ Could not pick a date'); await ss(page, '08-booking-no-date'); steps.push({ step: 'booking', status: 'PARTIAL', note: 'service selected, no date' }); } } catch (err) { console.log(` ❌ ${err.message.slice(0, 120)}`); steps.push({ step: 'booking', status: 'FAIL', error: err.message.slice(0, 80) }); } // ─── STEP 3: Logout ────────────────────────────────────── console.log('\n📍 Step 3: Logout'); try { await navigateTo(page, BASE_URL); await page.waitForTimeout(2000); const logoutResult = await page.evaluate(() => { const links = document.querySelectorAll('a, button'); for (const l of links) { const text = (l.textContent || '').trim().toLowerCase(); if (text === 'выйти' || text === 'logout' || text === 'sign out' || text === 'выйти из аккаунта') { l.click(); return text; } } return null; }); if (logoutResult) { console.log(` ✅ Clicked logout: "${logoutResult}"`); await page.waitForTimeout(2000); await ss(page, '09-logged-out'); steps.push({ step: 'logout', status: 'PASS' }); } else { console.log(' ⚠️ No logout button (probably not logged in)'); steps.push({ step: 'logout', status: 'SKIP', note: 'not logged in' }); } } catch (err) { console.log(` ❌ ${err.message.slice(0, 120)}`); steps.push({ step: 'logout', status: 'FAIL', error: err.message.slice(0, 80) }); } // ─── STEP 4: Login ─────────────────────────────────────── console.log('\n📍 Step 4: Login'); try { await navigateTo(page, BASE_URL); await page.waitForTimeout(2000); // Make sure auth modal is open await page.evaluate(() => { const authLinks = document.querySelectorAll('a, button'); for (const l of authLinks) { if ((l.textContent || '').trim() === 'Войти' || (l.textContent || '').trim() === 'ВОЙТИ') { l.click(); break; } } }); await page.waitForTimeout(1000); // Click "Войти" tab await page.click('a[href="#tab-login"]').catch(() => {}); await page.evaluate(() => { const links = document.querySelectorAll('a'); for (const a of links) { const text = (a.textContent || '').trim(); if (text === 'Войти' && a.href?.includes('#tab-login')) { a.click(); break; } } }); await page.waitForTimeout(1500); await ss(page, '10-login-tab'); // Fill login form await page.fill('#login-email', TEST_EMAIL).catch(() => {}); await page.fill('#login-password', TEST_PASSWORD).catch(() => {}); console.log(` Filled: ${TEST_EMAIL} / *******`); await ss(page, '11-login-filled'); // Click login button const loginBtn = await page.evaluate(() => { const btns = document.querySelectorAll('#tab-login button, .modal button'); for (const b of btns) { const text = (b.textContent || '').trim().toLowerCase(); if (text.includes('войти') || text.includes('login') || text.includes('вход')) { b.click(); return text.slice(0, 60); } } // Fallback: any submit in login tab const submit = document.querySelector('#tab-login button[type="submit"], #tab-login .btn'); if (submit) { submit.click(); return submit.textContent.trim().slice(0, 60); } // Try modal submit const modal = document.querySelector('.modal.show button:not([class*="close"])'); if (modal) { modal.click(); return modal.textContent.trim().slice(0, 60); } return null; }); if (loginBtn) { console.log(` Clicked: "${loginBtn}"`); await page.waitForTimeout(3000); await ss(page, '12-login-result'); const afterLogin = await page.evaluate(() => document.body?.innerText?.slice(0, 800) || ''); if (afterLogin.includes('кабинет') || afterLogin.includes('выйти') || afterLogin.includes('профил')) { console.log(' ✅ Login successful - cabinet/logout visible'); steps.push({ step: 'login', status: 'PASS' }); } else if (afterLogin.includes('неверн') || afterLogin.includes('ошибк') || afterLogin.includes('не найден')) { console.log(' ⚠️ Login failed (wrong credentials - registration may not have completed)'); steps.push({ step: 'login', status: 'PARTIAL', note: 'wrong credentials' }); } else { console.log(` Result: ${afterLogin.slice(0, 200)}`); steps.push({ step: 'login', status: 'DONE', note: 'submitted' }); } } else { console.log(' ⚠️ No login button'); steps.push({ step: 'login', status: 'SKIP', note: 'no button' }); } } catch (err) { console.log(` ❌ ${err.message.slice(0, 120)}`); steps.push({ step: 'login', status: 'FAIL', error: err.message.slice(0, 80) }); } // ─── STEP 5: Personal Cabinet ───────────────────────────── console.log('\n📍 Step 5: Personal Cabinet'); try { // Try clicking "Войти в кабинет" link const cabinetResult = await page.evaluate(() => { const links = document.querySelectorAll('a, button'); for (const l of links) { const text = (l.textContent || '').trim().toLowerCase(); if (text.includes('войти в кабинет') || text.includes('личный кабинет') || text.includes('мой кабинет') || text.includes('мои запис')) { l.click(); return text; } } return null; }); if (cabinetResult) { console.log(` ✅ Clicked: "${cabinetResult}"`); await page.waitForTimeout(3000); await ss(page, '13-cabinet'); const cabinetText = await page.evaluate(() => document.body?.innerText?.slice(0, 1500) || ''); console.log(` Cabinet preview: ${cabinetText.slice(0, 200)}`); // Look for appointments const appointments = await page.evaluate(() => { const appts = document.querySelectorAll('[class*="appointment"], [class*="booking"], [class*="record"], [class*="history"] li'); return Array.from(appts).slice(0, 5).map(a => (a.textContent || '').trim().slice(0, 100)); }); if (appointments.length > 0) { console.log(` Found ${appointments.length} appointments:`); appointments.forEach(a => console.log(` → ${a}`)); } steps.push({ step: 'cabinet', status: 'PASS', appointments: appointments.length }); } else { console.log(' ⚠️ No cabinet link found'); steps.push({ step: 'cabinet', status: 'SKIP', note: 'no cabinet link' }); } } catch (err) { console.log(` ❌ ${err.message.slice(0, 120)}`); steps.push({ step: 'cabinet', status: 'FAIL', error: err.message.slice(0, 80) }); } await browser.close(); // ─── Report ────────────────────────────────────────────── const report = { timestamp: new Date().toISOString(), targetUrl: BASE_URL, testEmail: TEST_EMAIL, steps, summary: { total: steps.length, passed: steps.filter(s => s.status === 'PASS' || s.status === 'DONE').length, partial: steps.filter(s => s.status === 'PARTIAL').length, failed: steps.filter(s => s.status === 'FAIL').length, skipped: steps.filter(s => s.status === 'SKIP').length, }, }; const rp = path.join(__dirname, '..', 'reports', 'e2e-booking-report.json'); ensureDir(path.dirname(rp)); fs.writeFileSync(rp, JSON.stringify(report, null, 2)); console.log('\n═══════════════════════════════════════════════════'); console.log(' 📊 RESULTS'); console.log(' ─────────────────────────────────────────────────'); steps.forEach(s => { const icon = s.status === 'PASS' ? '✅' : s.status === 'DONE' ? '✔️' : s.status === 'PARTIAL' ? '⚠️' : s.status === 'SKIP' ? '⏭️' : '❌'; console.log(` ${icon} ${s.step}: ${s.status}${s.note ? ' (' + s.note + ')' : ''}${s.error ? ' - ' + s.error : ''}`); }); console.log(` 📄 Report: ${rp}`); console.log('═══════════════════════════════════════════════════\n'); // ─── Gitea ─────────────────────────────────────────────── if (GITEA_ISSUE) { try { const body = [ '## 🧪 E2E Booking Flow: irina-vik.ru', '', `**Test email**: \`${TEST_EMAIL}\``, '', '| Step | Status | Details |', '|------|--------|---------|', ...steps.map(s => { const icon = s.status === 'PASS' ? '✅' : s.status === 'DONE' ? '✔️' : s.status === 'PARTIAL' ? '⚠️' : s.status === 'SKIP' ? '⏭️' : '❌'; return `| ${s.step} | ${icon} ${s.status} | ${s.note || s.error || s.service || ''} |`; }), ].join('\n'); const screens = fs.readdirSync(SCREENSHOT_DIR).filter(f => f.endsWith('.png')).map(f => path.join(SCREENSHOT_DIR, f)); if (screens.length > 0) { await gitea.uploadAndComment(GITEA_ISSUE, screens, body); console.log(` ✅ Posted ${screens.length} screenshots to Gitea #${GITEA_ISSUE}`); } else { await gitea.postComment(GITEA_ISSUE, body); console.log(` ✅ Posted comment to Gitea #${GITEA_ISSUE}`); } } catch (err) { console.error(` ❌ Gitea: ${err.message}`); } } } main().catch(err => { console.error('Fatal:', err); process.exit(1); });