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:
230
tests/scripts/compare-screenshots.js
Normal file
230
tests/scripts/compare-screenshots.js
Normal 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);
|
||||
});
|
||||
352
tests/scripts/console-error-monitor.js
Normal file
352
tests/scripts/console-error-monitor.js
Normal 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);
|
||||
});
|
||||
280
tests/scripts/link-checker.js
Normal file
280
tests/scripts/link-checker.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user