Files
APAW/tests/scripts/lib/gitea-client.js
NW c258d16ef5 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
2026-04-17 09:27:27 +01:00

263 lines
8.4 KiB
JavaScript

/**
* 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,
};