feat: add Gitea integration, E2E booking flow, Docker DNS fix, browser-launcher module

- 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
This commit is contained in:
NW
2026-04-17 09:27:27 +01:00
parent 3a8aa6b416
commit c258d16ef5
13 changed files with 1149 additions and 238 deletions

View File

@@ -13,6 +13,7 @@
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const { BASE_ARGS } = require('./lib/browser-launcher');
const TARGET_URL = process.env.TARGET_URL || 'http://host.docker.internal:3000';
const MODE = process.argv[2] || 'current';
@@ -43,7 +44,7 @@ async function captureScreenshots() {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
args: [...BASE_ARGS, '--disable-setuid-sandbox'],
});
let totalCaptured = 0;
@@ -65,7 +66,8 @@ async function captureScreenshots() {
const url = `${TARGET_URL}${page_config.path}`;
console.log(` Capturing: ${url} [${viewport.name}]`);
await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 });
await page.goto(url, { waitUntil: 'commit', timeout: 30000 });
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1000);
await page.screenshot({

View File

@@ -10,14 +10,18 @@
* Environment:
* TARGET_URL - App URL (default: http://host.docker.internal:3000)
* REPORTS_DIR - Reports output dir
* GITEA_ISSUE - Gitea issue number to post results (optional)
*/
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const gitea = require('./lib/gitea-client');
const { BASE_ARGS } = require('./lib/browser-launcher');
const TARGET_URL = process.env.TARGET_URL || 'http://host.docker.internal:3000';
const REPORTS_DIR = process.env.REPORTS_DIR || path.join(__dirname, '..', 'reports');
const GITEA_ISSUE = parseInt(process.env.GITEA_ISSUE, 10) || null;
const PAGES = [
{ name: 'homepage', path: '/' },
@@ -36,7 +40,7 @@ async function main() {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
args: [...BASE_ARGS, '--disable-setuid-sandbox'],
});
const allErrors = [];
@@ -81,7 +85,8 @@ async function main() {
});
try {
const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 });
const response = await page.goto(url, { waitUntil: 'commit', timeout: 30000 });
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
if (!response || response.status() >= 400) {
@@ -151,6 +156,17 @@ async function main() {
console.log(` 📄 Report: ${reportPath}`);
console.log('═══════════════════════════════════════════════════\n');
if (GITEA_ISSUE) {
try {
console.log(`📤 Posting results to Gitea Issue #${GITEA_ISSUE}...`);
const commentBody = gitea.formatConsoleReport(report);
await gitea.postComment(GITEA_ISSUE, commentBody);
console.log(' ✅ Posted comment to Gitea');
} catch (err) {
console.error(` ❌ Gitea posting failed: ${err.message}`);
}
}
process.exit(totalIssues > 0 ? 1 : 0);
}

View File

@@ -0,0 +1,512 @@
#!/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); });

View File

@@ -0,0 +1,64 @@
/**
* Shared browser launch configuration and navigation helpers.
*
* Fixes:
* - DNS resolution inside Docker (--dns-resolution-order=hostname-first)
* - Slow sites: uses waitUntil: 'commit' + waitForLoadState instead of 'networkidle'
* - UA fingerprinting: realistic Chrome user agent
*
* Usage:
* const { launchBrowser, navigateTo } = require('./lib/browser-launcher');
* const browser = await launchBrowser();
* const page = ...;
* await navigateTo(page, 'https://example.com');
*/
const { chromium } = require('playwright');
const USE_DNS_FIX = process.env.DNS_RESOLUTION_ORDER === 'hostname-first';
const BASE_ARGS = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-blink-features=AutomationControlled',
...(USE_DNS_FIX ? ['--dns-resolution-order=hostname-first'] : []),
];
const DEFAULT_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
async function launchBrowser(options = {}) {
const args = [...BASE_ARGS, ...(options.extraArgs || [])];
return chromium.launch({
headless: options.headless !== undefined ? options.headless : true,
args,
});
}
async function newContext(browser, options = {}) {
return browser.newContext({
viewport: { width: 1280, height: 720 },
deviceScaleFactor: 1,
userAgent: DEFAULT_UA,
...options,
});
}
async function navigateTo(page, url, options = {}) {
const waitUntil = options.waitUntil || 'commit';
const timeout = options.timeout || 60000;
const response = await page.goto(url, { waitUntil, timeout });
if (options.waitForDom !== false) {
await page.waitForLoadState('domcontentloaded', { timeout: options.domTimeout || 15000 }).catch(() => {});
}
const delay = options.delay || 2000;
if (delay > 0) await page.waitForTimeout(delay);
return response;
}
module.exports = { launchBrowser, newContext, navigateTo, BASE_ARGS, DEFAULT_UA };

View File

@@ -0,0 +1,263 @@
/**
* Gitea API Client — Lightweight helper for posting test results to Gitea Issues.
*
* Auth flow: Basic Auth → create token → use token for API calls.
*
* Usage:
* const gitea = require('./lib/gitea-client');
* await gitea.postComment(issueNumber, body);
* await gitea.uploadAttachment(issueNumber, filePath);
*
* Environment:
* GITEA_API_URL - API base (default: https://git.softuniq.eu/api/v1)
* GITEA_TOKEN - Pre-existing API token (skips Basic Auth if set)
* GITEA_USER - Username for Basic Auth (default: NW)
* GITEA_PASSWORD - Password for Basic Auth (required if no token)
* GITEA_REPO - Repository path (default: UniqueSoft/APAW)
*/
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const GITEA_API_URL = process.env.GITEA_API_URL || 'https://git.softuniq.eu/api/v1';
const GITEA_USER = process.env.GITEA_USER || '';
const GITEA_PASSWORD = process.env.GITEA_PASSWORD || '';
const GITEA_REPO = process.env.GITEA_REPO || 'UniqueSoft/APAW';
let _cachedToken = process.env.GITEA_TOKEN || null;
function request(urlStr, options, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlStr);
const mod = url.protocol === 'https:' ? https : http;
const opts = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: options.method || 'GET',
headers: options.headers || {},
};
const req = mod.request(opts, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try { resolve(JSON.parse(data)); } catch { resolve(data); }
} else {
reject(new Error(`Gitea API ${res.statusCode}: ${data.slice(0, 300)}`));
}
});
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
async function getToken() {
if (_cachedToken) return _cachedToken;
const credentials = Buffer.from(`${GITEA_USER}:${GITEA_PASSWORD}`).toString('base64');
const urlStr = `${GITEA_API_URL}/users/${GITEA_USER}/tokens`;
const body = JSON.stringify({ name: `vt-${Date.now()}`, scopes: ['all'] });
const result = await request(urlStr, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${credentials}`,
},
}, body);
_cachedToken = result.sha1;
return _cachedToken;
}
async function authHeaders() {
const token = await getToken();
return { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' };
}
async function postComment(issueNumber, body) {
const headers = await authHeaders();
const url = `${GITEA_API_URL}/repos/${GITEA_REPO}/issues/${issueNumber}/comments`;
return request(url, { method: 'POST', headers }, JSON.stringify({ body }));
}
async function uploadAttachment(issueNumber, filePath) {
const token = await getToken();
const fileContent = fs.readFileSync(filePath);
const filename = path.basename(filePath);
const boundary = `----FormBoundary${Date.now()}`;
let body = `--${boundary}\r\n`.getBytes?.() || Buffer.from(`--${boundary}\r\n`);
body = Buffer.concat([
Buffer.from(`--${boundary}\r\n`),
Buffer.from(`Content-Disposition: form-data; name="attachment"; filename="${filename}"\r\n`),
Buffer.from(`Content-Type: image/png\r\n\r\n`),
fileContent,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const url = new URL(`${GITEA_API_URL}/repos/${GITEA_REPO}/issues/${issueNumber}/assets`);
const mod = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = mod.request({
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers: {
'Authorization': `token ${token}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': body.length,
},
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try { resolve(JSON.parse(data)); } catch { resolve(data); }
} else {
reject(new Error(`Gitea upload ${res.statusCode}: ${data.slice(0, 300)}`));
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
async function uploadAndComment(issueNumber, filePaths, commentBody) {
const uuids = [];
for (const fp of filePaths) {
try {
const result = await uploadAttachment(issueNumber, fp);
uuids.push({ filename: path.basename(fp), uuid: result.uuid });
} catch (err) {
console.error(` ⚠️ Upload failed ${path.basename(fp)}: ${err.message}`);
}
}
let fullBody = commentBody;
if (uuids.length > 0) {
fullBody += '\n\n### 📸 Screenshots\n\n';
for (const u of uuids) {
fullBody += `![${u.filename}](/attachments/${u.uuid})\n`;
}
}
return postComment(issueNumber, fullBody);
}
function formatVisualReport(report) {
const s = report.summary;
const lines = [
'## 📊 Visual Test Results',
'',
`**URL**: \`${report.targetUrl}\``,
`**Pages**: ${report.pages.join(', ')}`,
`**Viewports**: ${report.viewports.join(', ')}`,
`**Threshold**: ${report.threshold * 100}%`,
'',
'### Summary',
'',
`| Metric | Count |`,
`|--------|-------|`,
`| Screenshots captured | ${s.screenshotsCaptured} |`,
`| Screenshots failed | ${s.screenshotsFailed} |`,
`| Comparisons passed | ${s.comparisonsPassed} |`,
`| Comparisons failed | ${s.comparisonsFailed} |`,
`| UI elements extracted | ${s.totalElements} |`,
`| Console errors | ${s.totalConsoleErrors} |`,
`| Network errors | ${s.totalNetworkErrors} |`,
'',
`**Overall**: ${s.overallPassed ? '✅ PASSED' : '❌ FAILED'}`,
];
if (report.comparison?.length) {
lines.push('', '### Comparison Details', '');
lines.push('| Screenshot | Status | Diff % |');
lines.push('|------------|--------|--------|');
for (const c of report.comparison) {
lines.push(`| ${c.filename} | ${c.status === 'PASS' ? '✅' : '❌'} ${c.status} | ${c.diffPercent || 'N/A'} |`);
}
}
if (report.consoleErrors?.length > 0) {
lines.push('', '### Console Errors', '');
for (const e of report.consoleErrors.slice(0, 5)) {
lines.push(`- [${e.page}/${e.viewport}] ${e.error?.slice(0, 120)}`);
}
if (report.consoleErrors.length > 5) {
lines.push(`- ... and ${report.consoleErrors.length - 5} more`);
}
}
if (report.networkErrors?.length > 0) {
lines.push('', '### Network Errors', '');
for (const e of report.networkErrors.slice(0, 5)) {
lines.push(`- [${e.page}/${e.viewport}] ${e.status || e.failure} ${e.url?.slice(0, 80)}`);
}
if (report.networkErrors.length > 5) {
lines.push(`- ... and ${report.networkErrors.length - 5} more`);
}
}
return lines.join('\n');
}
function formatConsoleReport(report) {
const s = report.summary;
const lines = [
'## 📊 Console Error Monitor Results',
'',
`**URL**: \`${report.targetUrl}\``,
`**Pages**: ${report.pages.join(', ')}`,
'',
'### Summary',
'',
`| Metric | Count |`,
`|--------|-------|`,
`| Console errors | ${s.consoleErrors} |`,
`| Console warnings | ${s.consoleWarnings} |`,
`| Network errors | ${s.networkErrors} |`,
`| **Total issues** | **${s.totalIssues}** |`,
'',
`**Status**: ${s.totalIssues === 0 ? '✅ CLEAN' : '❌ ISSUES FOUND'}`,
];
if (report.consoleErrors?.length > 0) {
lines.push('', '### Console Errors', '');
for (const e of report.consoleErrors.slice(0, 8)) {
lines.push(`- [${e.page}] ${e.text?.slice(0, 120)}`);
}
if (report.consoleErrors.length > 8) {
lines.push(`- ... and ${report.consoleErrors.length - 8} more`);
}
}
if (report.networkErrors?.length > 0) {
lines.push('', '### Network Errors', '');
for (const e of report.networkErrors.slice(0, 8)) {
lines.push(`- [${e.page}] ${e.status || e.failure} ${e.url?.slice(0, 80)}`);
}
if (report.networkErrors.length > 8) {
lines.push(`- ... and ${report.networkErrors.length - 8} more`);
}
}
return lines.join('\n');
}
module.exports = {
postComment,
uploadAttachment,
uploadAndComment,
formatVisualReport,
formatConsoleReport,
};

View File

@@ -11,16 +11,20 @@
* TARGET_URL - App URL (default: http://host.docker.internal:3000)
* PIXELMATCH_THRESHOLD - Diff threshold (default: 0.05 = 5%)
* PAGES - Comma-separated page paths (default: /,/admin/login)
* GITEA_ISSUE - Gitea issue number to post results (optional)
*/
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const gitea = require('./lib/gitea-client');
const { BASE_ARGS } = require('./lib/browser-launcher');
const TARGET_URL = process.argv[2] || process.env.TARGET_URL || 'http://host.docker.internal:3000';
const THRESHOLD = parseFloat(process.env.PIXELMATCH_THRESHOLD || '0.05');
const PAGES_ARG = process.env.PAGES || '/,/admin/login';
const PAGE_PATHS = PAGES_ARG.split(',').map(p => p.trim()).filter(Boolean);
const GITEA_ISSUE = parseInt(process.env.GITEA_ISSUE, 10) || null;
const VISUAL_DIR = path.join(__dirname, '..', 'visual');
const BASELINE_DIR = path.join(VISUAL_DIR, 'baseline');
@@ -128,7 +132,8 @@ async function capturePage(browser, pageConf, vp, outputDir, mode) {
try {
console.log(` Capturing: ${pageConf.name} @ ${vp.name} (${vp.width}x${vp.height})`);
const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
const response = await page.goto(url, { waitUntil: 'commit', timeout: 30000 });
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await page.screenshot({ path: filePath, fullPage: true });
@@ -161,7 +166,7 @@ async function captureAll(mode) {
console.log(` Pages: ${PAGES.map(p => p.path).join(', ')}`);
console.log(` Output: ${outputDir}\n`);
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
const browser = await chromium.launch({ headless: true, args: [...BASE_ARGS, '--disable-setuid-sandbox'] });
const results = [];
for (const pageConf of PAGES) {
@@ -319,6 +324,33 @@ async function main() {
console.log(`\n 📄 Report: ${reportPath}`);
console.log('═══════════════════════════════════════════════════\n');
if (GITEA_ISSUE) {
try {
console.log(`📤 Posting results to Gitea Issue #${GITEA_ISSUE}...`);
const commentBody = gitea.formatVisualReport(report);
const diffFiles = fs.existsSync(DIFF_DIR)
? fs.readdirSync(DIFF_DIR).filter(f => f.endsWith('.png')).map(f => path.join(DIFF_DIR, f))
: [];
const currentFiles = fs.existsSync(CURRENT_DIR)
? fs.readdirSync(CURRENT_DIR).filter(f => f.endsWith('.png')).map(f => path.join(CURRENT_DIR, f))
: [];
if (diffFiles.length > 0) {
await gitea.uploadAndComment(GITEA_ISSUE, diffFiles, commentBody);
console.log(` ✅ Posted comment with ${diffFiles.length} diff screenshots`);
} else if (currentFiles.length > 0) {
await gitea.uploadAndComment(GITEA_ISSUE, currentFiles, commentBody);
console.log(` ✅ Posted comment with ${currentFiles.length} current screenshots`);
} else {
await gitea.postComment(GITEA_ISSUE, commentBody);
console.log(' ✅ Posted comment (no screenshots to upload)');
}
} catch (err) {
console.error(` ❌ Gitea posting failed: ${err.message}`);
}
}
process.exit(report.summary.overallPassed ? 0 : 1);
}