- Add tests/scripts/lib/gitea-client.js: Gitea API client with auth, comments,
attachments, and Markdown report formatters for visual and console reports
- Add tests/scripts/lib/browser-launcher.js: shared Playwright launch config with
--dns-resolution-order=hostname-first, realistic UA, and navigateTo() helper
using waitUntil:'commit' + waitForLoadState('domcontentloaded')
- Add tests/scripts/e2e-booking-flow-v2.js: full E2E scenario for irina-vik.ru
(register → book service → login → personal cabinet) with Gitea reporting
- Update visual-test-pipeline.js: GITEA_ISSUE env var, Gitea comment+attachment
posting, browser-launcher integration, waitUntil:'commit' navigation
- Update console-error-monitor-standalone.js: same Gitea + DNS fixes
- Update capture-screenshots.js: browser-launcher integration, DNS fix
- Update docker-compose.web-testing.yml: NETWORK_MODE env var (bridge),
DNS_RESOLUTION_ORDER, GITEA_USER/PASSWORD env passthrough, e2e-booking service
- Update tests/package.json: pin playwright to exact 1.52.0 (matches Docker image)
- Update .gitignore: add tests/visual/e2e/ for E2E screenshots
- Update .kilo/agents/visual-tester.md: Docker networking note, Gitea scripts,
e2e-booking service, updated script table
- Update .kilo/commands/web-test.md: Docker Networking section, --issue flag,
Gitea Integration section, e2e-booking service
- Update .kilo/commands/e2e-test.md: complete rewrite — Docker-based Playwright,
no more MCP dependency, proper service table, Gitea integration docs
- Update .kilo/capability-index.yaml: add gitea_integration, e2e_booking_flow,
docker_networking capabilities to visual-tester; add routing entries
512 lines
22 KiB
JavaScript
512 lines
22 KiB
JavaScript
#!/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); }); |