feat: add web testing infrastructure

- Docker configurations for Playwright MCP (no host pollution)
- Visual regression testing with pixelmatch
- Link checking for 404/500 errors
- Console error detection with Gitea issue creation
- Form testing capabilities
- /web-test and /web-test-fix commands
- web-testing skill documentation
- Reorganize project structure (docker/, scripts/, tests/)
- Update orchestrator model to ollama-cloud/glm-5

Structure:
- docker/ - Docker configurations (moved from archive)
- scripts/ - Utility scripts
- tests/ - Test suite with visual, console, links testing
- .kilo/commands/ - /web-test and /web-test-fix commands
- .kilo/skills/ - web-testing skill

Issues: #58 #60 #62
This commit is contained in:
¨NW¨
2026-04-07 08:55:24 +01:00
parent b9abd91d07
commit e074612046
15 changed files with 2862 additions and 1 deletions

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env node
/**
* Visual Regression Testing Script
*
* Compares current screenshots with baseline using pixelmatch
* Reports visual differences: overlaps, font shifts, color mismatches
*
* Usage: node compare-screenshots.js [options]
* Options:
* --threshold 0.05 - Pixel difference threshold (default: 5%)
* --baseline ./baseline - Baseline directory
* --current ./current - Current screenshots directory
* --diff ./diff - Diff output directory
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Configuration
const config = {
baselineDir: process.env.BASELINE_DIR || './tests/visual/baseline',
currentDir: process.env.CURRENT_DIR || './tests/visual/current',
diffDir: process.env.DIFF_DIR || './tests/visual/diff',
reportsDir: process.env.REPORTS_DIR || './tests/reports',
threshold: parseFloat(process.env.PIXELMATCH_THRESHOLD || '0.05'),
};
// Ensure directories exist
[config.diffDir, config.reportsDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
/**
* Compare two PNG images using pixelmatch
*/
async function compareImages(baselinePath, currentPath, diffPath) {
const pixelmatch = require('pixelmatch');
const PNG = require('pngjs').PNG;
const baselineImg = PNG.sync.read(fs.readFileSync(baselinePath));
const currentImg = PNG.sync.read(fs.readFileSync(currentPath));
const { width, height } = baselineImg;
// Check if sizes match
if (width !== currentImg.width || height !== currentImg.height) {
return {
success: false,
error: `Size mismatch: baseline ${width}x${height} vs current ${currentImg.width}x${currentImg.height}`,
diffPixels: -1,
totalPixels: width * height,
};
}
// Create diff image
const diffImg = new PNG({ width, height });
// Compare
const diffPixels = pixelmatch(
baselineImg.data,
currentImg.data,
diffImg.data,
width,
height,
{
threshold: 0.1, // Pixel similarity threshold
diffColor: [255, 0, 0], // Red for differences
diffColorAlt: [255, 255, 0], // Yellow for anti-aliased
}
);
// Save diff image
fs.writeFileSync(diffPath, PNG.sync.write(diffImg));
const diffPercent = (diffPixels / (width * height)) * 100;
return {
success: diffPercent <= (config.threshold * 100),
diffPixels,
totalPixels: width * height,
diffPercent: diffPercent.toFixed(2),
width,
height,
};
}
/**
* Detect specific visual issues
*/
function detectVisualIssues(baselinePath, currentPath) {
// This would ideally use Playwright for element-level analysis
// For now, return generic analysis
return {
potentialIssues: [
'element_overlap',
'font_shift',
'color_mismatch',
'layout_break',
]
};
}
/**
* Get all PNG files from a directory
*/
function getPNGFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter(f => f.endsWith('.png'))
.map(f => path.basename(f, '.png'));
}
/**
* Main comparison function
*/
async function main() {
console.log('=== Visual Regression Testing ===\n');
console.log(`Baseline: ${config.baselineDir}`);
console.log(`Current: ${config.currentDir}`);
console.log(`Diff: ${config.diffDir}`);
console.log(`Threshold: ${config.threshold * 100}%\n`);
const baselineFiles = getPNGFiles(config.baselineDir);
const currentFiles = getPNGFiles(config.currentDir);
const results = [];
let passed = 0;
let failed = 0;
let missing = 0;
// Check for missing baselines
for (const file of currentFiles) {
if (!baselineFiles.includes(file)) {
console.log(`⚠️ New screenshot: ${file}`);
missing++;
results.push({
name: file,
status: 'NEW',
message: 'No baseline exists - will be created as baseline',
});
}
}
// Compare existing baselines
for (const file of baselineFiles) {
const baselinePath = path.join(config.baselineDir, `${file}.png`);
const currentPath = path.join(config.currentDir, `${file}.png`);
const diffPath = path.join(config.diffDir, `${file}_diff.png`);
if (!fs.existsSync(currentPath)) {
console.log(`❌ Missing: ${file}`);
failed++;
results.push({
name: file,
status: 'MISSING',
message: 'Current screenshot not found',
});
continue;
}
try {
console.log(`🔍 Comparing: ${file}...`);
const result = await compareImages(baselinePath, currentPath, diffPath);
if (result.success) {
console.log(`✅ PASS: ${file} (${result.diffPercent}% diff)`);
passed++;
} else {
console.log(`❌ FAIL: ${file} (${result.diffPercent}% diff)`);
console.log(` ${result.diffPixels} pixels changed of ${result.totalPixels}`);
failed++;
}
results.push({
name: file,
status: result.success ? 'PASS' : 'FAIL',
diffPercent: result.diffPercent,
diffPixels: result.diffPixels,
totalPixels: result.totalPixels,
width: result.width,
height: result.height,
diffPath: diffPath,
});
} catch (error) {
console.log(`❌ ERROR: ${file} - ${error.message}`);
failed++;
results.push({
name: file,
status: 'ERROR',
message: error.message,
});
}
}
// Generate report
const report = {
timestamp: new Date().toISOString(),
threshold: config.threshold,
summary: {
total: baselineFiles.length,
passed,
failed,
missing,
newScreenshots: missing,
},
results,
};
const reportPath = path.join(config.reportsDir, 'visual-regression-report.json');
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`\n📊 Summary:`);
console.log(` Total: ${baselineFiles.length}`);
console.log(` ✅ Pass: ${passed}`);
console.log(` ❌ Fail: ${failed}`);
console.log(` ⚠️ New: ${missing}`);
console.log(`\n📄 Report saved to: ${reportPath}`);
// Exit with error code if failures
process.exit(failed > 0 ? 1 : 0);
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,352 @@
#!/usr/bin/env node
/**
* Console Error Aggregator
*
* Collects all console errors from Playwright sessions
* Reports: error message, file, line number, stack trace
* Auto-creates Gitea Issues for critical errors
*/
const http = require('http');
const https = require('https');
const { URL } = require('url');
// Configuration
const config = {
playwrightMcpUrl: process.env.PLAYWRIGHT_MCP_URL || 'http://localhost:8931/mcp',
giteaApiUrl: process.env.GITEA_API_URL || 'https://git.softuniq.eu/api/v1',
giteaToken: process.env.GITEA_TOKEN || '',
giteaRepo: process.env.GITEA_REPO || 'UniqueSoft/APAW',
targetUrl: process.env.TARGET_URL || 'http://localhost:3000',
reportsDir: process.env.REPORTS_DIR || './reports',
autoCreateIssues: process.env.AUTO_CREATE_ISSUES === 'true',
ignoredPatterns: (process.env.IGNORED_ERROR_PATTERNS || '').split(','),
};
/**
* Make HTTP request to Playwright MCP
*/
async function mcpRequest(method, params) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method,
params,
});
const url = new URL(config.playwrightMcpUrl);
const req = http.request({
hostname: url.hostname,
port: url.port || 8931,
path: '/mcp',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', reject);
req.write(body);
req.end();
});
}
/**
* Navigate to URL
*/
async function navigateTo(url) {
return mcpRequest('tools/call', {
name: 'browser_navigate',
arguments: { url },
});
}
/**
* Get console messages
*/
async function getConsoleMessages(level = 'error', all = true) {
return mcpRequest('tools/call', {
name: 'browser_console_messages',
arguments: { level, all },
});
}
/**
* Get network requests (for failed requests)
*/
async function getNetworkRequests(filter = 'failed') {
return mcpRequest('tools/call', {
name: 'browser_network_requests',
arguments: { filter },
});
}
/**
* Take screenshot for error context
*/
async function takeScreenshot(filename) {
return mcpRequest('tools/call', {
name: 'browser_take_screenshot',
arguments: { filename },
});
}
/**
* Parse console error to extract file and line number
*/
function parseErrorDetails(error) {
const result = {
message: error,
file: null,
line: null,
column: null,
stack: [],
};
// Try to parse stack trace
const stackMatch = error.match(/at\s+(?:(.+)\s+\()?([^:]+):(\d+):(\d+)\)?/);
if (stackMatch) {
result.file = stackMatch[2];
result.line = parseInt(stackMatch[3]);
result.column = parseInt(stackMatch[4]);
}
// Parse Chrome-style stack traces
const chromePattern = /at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/g;
let match;
while ((match = chromePattern.exec(error)) !== null) {
result.stack.push({
function: match[1],
file: match[2],
line: parseInt(match[3]),
column: parseInt(match[4]),
});
}
return result;
}
/**
* Check if error should be ignored
*/
function shouldIgnoreError(error) {
const message = error.message || error;
return config.ignoredPatterns.some(pattern =>
pattern && message.includes(pattern)
);
}
/**
* Create Gitea Issue for error
*/
async function createGiteaIssue(errorData) {
if (!config.giteaToken || !config.autoCreateIssues) {
return null;
}
const fs = require('fs');
const path = require('path');
const title = `[Console Error] ${errorData.parsed.message.slice(0, 100)}`;
const body = `## Console Error
**Error Type**: ${errorData.type}
**Message**:
\`\`\`
${errorData.parsed.message}
\`\`\`
**Location**: ${errorData.parsed.file || 'Unknown'}:${errorData.parsed.line || '?'}
**Page URL**: ${errorData.pageUrl}
### Stack Trace
\`\`\`
${errorData.parsed.stack.map(s => `${s.function} (${s.file}:${s.line}:${s.column})`).join('\n') || 'No stack trace available'}
\`\`\`
## Auto-Fix Required
- [ ] Investigate the root cause
- [ ] Implement fix
- [ ] Add test case
- [ ] Verify fix
---
**Detected by**: Kilo Code Web Testing
`;
return new Promise((resolve, reject) => {
const url = new URL(`${config.giteaApiUrl}/repos/${config.giteaRepo}/issues`);
const bodyData = JSON.stringify({ title, body });
const client = url.protocol === 'https:' ? https : http;
const req = client.request({
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${config.giteaToken}`,
'Content-Length': Buffer.byteLength(bodyData),
},
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.write(bodyData);
req.end();
});
}
/**
* Main console monitoring function
*/
async function main() {
console.log('=== Console Error Monitor ===\n');
console.log(`Target URL: ${config.targetUrl}`);
console.log(`Auto-create Issues: ${config.autoCreateIssues}\n`);
const errors = {
consoleErrors: [],
networkErrors: [],
uncaughtExceptions: [],
};
try {
// Navigate to target
console.log('📡 Navigating to target URL...');
await navigateTo(config.targetUrl);
// Wait a bit for page to load
await new Promise(resolve => setTimeout(resolve, 2000));
// Get console messages
console.log('🔍 Collecting console messages...');
const consoleResult = await getConsoleMessages('error', true);
if (consoleResult.result?.content) {
const messages = consoleResult.result.content;
for (const msg of messages) {
if (shouldIgnoreError(msg)) {
console.log(' ⏭️ Ignored:', msg.slice(0, 80));
continue;
}
const parsed = parseErrorDetails(msg);
const errorData = {
type: 'console',
message: msg,
parsed,
pageUrl: config.targetUrl,
timestamp: new Date().toISOString(),
};
errors.consoleErrors.push(errorData);
console.log(' ❌ Console Error:', msg.slice(0, 80));
}
}
// Get failed network requests
console.log('🔍 Checking network requests...');
const networkResult = await getNetworkRequests('failed');
if (networkResult.result?.content) {
for (const req of networkResult.result.content) {
if (req.status >= 400) {
errors.networkErrors.push({
type: 'network',
url: req.url,
status: req.status,
method: req.method,
pageUrl: config.targetUrl,
timestamp: new Date().toISOString(),
});
console.log(` ❌ Network Error: ${req.status} ${req.url}`);
}
}
}
// Take screenshot for context
const screenshotFilename = `error-context-${Date.now()}.png`;
await takeScreenshot(screenshotFilename);
console.log(`📸 Screenshot saved: ${screenshotFilename}`);
// Create Gitea Issues for critical errors
if (config.autoCreateIssues) {
console.log('\n📝 Creating Gitea Issues...');
for (const error of errors.consoleErrors) {
try {
const issue = await createGiteaIssue(error);
error.giteaIssue = issue?.html_url || null;
if (issue) {
console.log(` ✅ Issue created: ${issue.html_url}`);
error.issueNumber = issue.number;
}
} catch (err) {
console.log(` ❌ Failed to create issue: ${err.message}`);
}
}
}
} catch (error) {
console.error('Error during monitoring:', error.message);
}
// Generate report
const fs = require('fs');
const path = require('path');
const report = {
timestamp: new Date().toISOString(),
config: {
targetUrl: config.targetUrl,
autoCreateIssues: config.autoCreateIssues,
},
summary: {
consoleErrors: errors.consoleErrors.length,
networkErrors: errors.networkErrors.length,
totalErrors: errors.consoleErrors.length + errors.networkErrors.length,
},
errors,
};
const reportPath = path.join(config.reportsDir, 'console-errors-report.json');
if (!fs.existsSync(config.reportsDir)) {
fs.mkdirSync(config.reportsDir, { recursive: true });
}
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log('\n📊 Summary:');
console.log(` Console Errors: ${errors.consoleErrors.length}`);
console.log(` Network Errors: ${errors.networkErrors.length}`);
console.log(` Total Errors: ${report.summary.totalErrors}`);
console.log(`\n📄 Report saved to: ${reportPath}`);
// Exit with error if errors found
process.exit(report.summary.totalErrors > 0 ? 1 : 0);
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env node
/**
* Link Checker Script for Web Applications
*
* Finds all links on pages and checks for broken ones (404, 500, etc.)
* Reports broken links with context (page URL, link text)
*/
const http = require('http');
const https = require('https');
const { URL } = require('url');
// Playwright MCP endpoint
const MCP_ENDPOINT = process.env.PLAYWRIGHT_MCP_URL || 'http://localhost:8931/mcp';
// Configuration
const config = {
targetUrl: process.env.TARGET_URL || 'http://localhost:3000',
maxDepth: parseInt(process.env.MAX_DEPTH || '2'),
timeout: parseInt(process.env.TIMEOUT || '5000'),
concurrency: parseInt(process.env.CONCURRENCY || '5'),
ignorePatterns: (process.env.IGNORE_PATTERNS || '').split(','),
reportsDir: process.env.REPORTS_DIR || './reports',
};
/**
* Make HTTP request to Playwright MCP
*/
async function mcpRequest(method, params) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method,
params,
});
const url = new URL(MCP_ENDPOINT);
const options = {
hostname: url.hostname,
port: url.port,
path: url.path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
};
const client = url.protocol === 'https:' ? https : http;
const req = client.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.setTimeout(config.timeout, () => {
req.destroy();
reject(new Error('Timeout'));
});
req.write(body);
req.end();
});
}
/**
* Navigate to URL using Playwright MCP
*/
async function navigateTo(url) {
const result = await mcpRequest('tools/call', {
name: 'browser_navigate',
arguments: { url },
});
return result;
}
/**
* Get page snapshot with all links
*/
async function getPageSnapshot() {
const result = await mcpRequest('tools/call', {
name: 'browser_snapshot',
arguments: {},
});
return result;
}
/**
* Extract links from accessibility tree
*/
function extractLinks(snapshot) {
// Parse accessibility tree for links
const links = [];
// This would parse the snapshot content returned by Playwright MCP
// For now, return placeholder
return links;
}
/**
* Check if a URL is valid
*/
async function checkUrl(url, baseUrl) {
return new Promise((resolve) => {
try {
const parsedUrl = new URL(url, baseUrl);
// Skip anchor links
if (url.startsWith('#')) {
resolve({ url, status: 'SKIP', message: 'Anchor link' });
return;
}
// Skip mailto and tel links
if (parsedUrl.protocol === 'mailto:' || parsedUrl.protocol === 'tel:') {
resolve({ url, status: 'SKIP', message: 'Non-HTTP protocol' });
return;
}
// Check ignore patterns
for (const pattern of config.ignorePatterns) {
if (pattern && url.includes(pattern)) {
resolve({ url, status: 'SKIP', message: 'Ignored pattern' });
return;
}
}
// Make HEAD request to check URL
const client = parsedUrl.protocol === 'https:' ? https : http;
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname + parsedUrl.search,
method: 'HEAD',
timeout: config.timeout,
};
const req = client.request(options, (res) => {
resolve({
url,
status: res.statusCode >= 400 ? 'BROKEN' : 'OK',
statusCode: res.statusCode,
});
});
req.on('error', (err) => {
resolve({ url, status: 'ERROR', message: err.message });
});
req.on('timeout', () => {
req.destroy();
resolve({ url, status: 'TIMEOUT', message: 'Request timed out' });
});
req.end();
} catch (err) {
resolve({ url, status: 'ERROR', message: err.message });
}
});
}
/**
* Main link checking function
*/
async function main() {
console.log('=== Link Checker ===\n');
console.log(`Target URL: ${config.targetUrl}`);
console.log(`Max Depth: ${config.maxDepth}\n`);
const visitedUrls = new Set();
const brokenLinks = [];
const allLinks = [];
// Connect to Playwright MCP
console.log('📡 Connecting to Playwright MCP...');
// Start with target URL
const toVisit = [config.targetUrl];
while (toVisit.length > 0) {
const url = toVisit.shift();
if (visitedUrls.has(url)) {
continue;
}
visitedUrls.add(url);
console.log(`🔍 Checking: ${url}`);
try {
// Navigate to URL
await navigateTo(url);
// Get page content
const snapshot = await getPageSnapshot();
const links = extractLinks(snapshot);
// Check each link
for (const link of links) {
const result = await checkUrl(link.href, url);
allLinks.push({
sourcePage: url,
linkText: link.text || '[no text]',
href: link.href,
...result,
});
if (result.status === 'BROKEN' || result.status === 'ERROR') {
brokenLinks.push(allLinks[allLinks.length - 1]);
console.log(`${link.href} - ${result.statusCode || result.message}`);
} else {
console.log(`${link.href}`);
}
// Add to visit queue if same origin
if (result.status === 'OK') {
try {
const parsedUrl = new URL(link.href, config.targetUrl);
const parsedBaseUrl = new URL(config.targetUrl);
if (parsedUrl.origin === parsedBaseUrl.origin) {
toVisit.push(link.href);
}
} catch (e) {
// Skip invalid URLs
}
}
}
} catch (error) {
console.log(`❌ Error checking ${url}: ${error.message}`);
brokenLinks.push({
sourcePage: url,
href: url,
status: 'ERROR',
message: error.message,
});
}
}
// Generate report
const report = {
timestamp: new Date().toISOString(),
config,
summary: {
totalLinks: allLinks.length,
brokenLinks: brokenLinks.length,
pagesChecked: visitedUrls.size,
},
allLinks,
brokenLinks,
};
const fs = require('fs');
const path = require('path');
const reportPath = path.join(config.reportsDir, 'link-check-report.json');
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`\n📊 Summary:`);
console.log(` Pages Checked: ${visitedUrls.size}`);
console.log(` Total Links: ${allLinks.length}`);
console.log(` Broken Links: ${brokenLinks.length}`);
console.log(`\n📄 Report saved to: ${reportPath}`);
// Exit with error if broken links found
process.exit(brokenLinks.length > 0 ? 1 : 0);
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});