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