Commit 0c6036f0 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add browser-accessible smoke test at /tools/smoke-test

- Visit /tools/smoke-test in browser to get full HTML report
- Dark UI with stats cards, error details, and "Copy for Claude" button
- Requires settings.edit permission (admin only)
- Still works via CLI: php tools/smoke_test.php [url] [email] [pass]
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent b60d3139
<?php
declare(strict_types=1);
namespace App\Modules\Settings\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
class SmokeTestController extends Controller
{
public function run(Request $request): Response
{
$this->authorize('settings.edit');
ob_start();
require __DIR__ . '/../../../../tools/smoke_test.php';
$html = ob_get_clean();
return (new Response())->html($html);
}
}
......@@ -11,4 +11,7 @@ return [
['POST', '/settings/branding/logo', 'Settings\Controllers\BrandingController@updateLogo', ['auth', 'csrf'], 'settings.edit'],
['POST', '/settings/branding/carnet', 'Settings\Controllers\BrandingController@updateCarnetDesign', ['auth', 'csrf'], 'settings.edit'],
['POST', '/settings/branding/receipt', 'Settings\Controllers\BrandingController@updateReceiptDesign',['auth', 'csrf'], 'settings.edit'],
// Smoke Test Tool
['GET', '/tools/smoke-test', 'Settings\Controllers\SmokeTestController@run', ['auth'], 'settings.edit'],
];
\ No newline at end of file
......@@ -3,23 +3,30 @@
* ERP Smoke Tester — UI-level automated testing
*
* Crawls every route, loads every page, checks for PHP errors,
* SQL errors, broken views, and slow pages. Outputs a structured
* report you can paste to Claude for automated fixing.
* SQL errors, broken views, and slow pages.
*
* Usage:
* php tools/smoke_test.php [base_url] [email] [password]
* Example: php tools/smoke_test.php https://your-erp.com admin@club.com admin123
* Access via browser: /tools/smoke-test (requires super_admin)
* Access via CLI: php tools/smoke_test.php [base_url] [email] [password]
*/
declare(strict_types=1);
$isCli = php_sapi_name() === 'cli';
// ─── Configuration ──────────────────────────────────────────────────────────
$baseUrl = $argv[1] ?? getenv('APP_URL') ?: 'http://localhost';
if ($isCli) {
$baseUrl = $argv[1] ?? getenv('APP_URL') ?: 'http://localhost';
$adminEmail = $argv[2] ?? getenv('ADMIN_EMAIL') ?: 'admin@club.com';
$adminPassword = $argv[3] ?? getenv('ADMIN_PASSWORD') ?: 'admin123';
} else {
// Browser mode — auto-detect URL and use session auth
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$baseUrl = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
$adminEmail = $_POST['email'] ?? $_GET['email'] ?? 'admin@club.com';
$adminPassword = $_POST['password'] ?? $_GET['password'] ?? 'admin123';
}
$baseUrl = rtrim($baseUrl, '/');
$adminEmail = $argv[2] ?? getenv('ADMIN_EMAIL') ?: 'admin@club.com';
$adminPassword = $argv[3] ?? getenv('ADMIN_PASSWORD') ?: 'admin123';
$timeout = 15;
$maxPages = 500;
......@@ -35,8 +42,7 @@ $results = [
'start_time' => microtime(true),
];
$isCli = php_sapi_name() === 'cli';
// ─── Output Helpers ─────────────────────────────────────────────────────────
function output(string $msg, string $type = 'info'): void {
global $isCli;
if ($isCli) {
......@@ -45,6 +51,44 @@ function output(string $msg, string $type = 'info'): void {
}
}
// ─── Browser: Output HTML header ────────────────────────────────────────────
if (!$isCli) {
if (!ob_get_level()) header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html><html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>ERP Smoke Test</title>';
echo '<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f172a; color: #e2e8f0; padding: 20px; line-height: 1.6; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { color: #38bdf8; margin-bottom: 5px; font-size: 24px; }
.subtitle { color: #64748b; margin-bottom: 20px; font-size: 14px; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 20px; }
.stat { background: #1e293b; border-radius: 8px; padding: 15px; text-align: center; border: 1px solid #334155; }
.stat .num { font-size: 28px; font-weight: 700; }
.stat .label { font-size: 12px; color: #94a3b8; margin-top: 4px; }
.stat.pass .num { color: #4ade80; }
.stat.fail .num { color: #f87171; }
.stat.skip .num { color: #fbbf24; }
.stat.total .num { color: #38bdf8; }
.section { background: #1e293b; border-radius: 8px; padding: 20px; margin-bottom: 15px; border: 1px solid #334155; }
.section h2 { font-size: 16px; margin-bottom: 12px; color: #f87171; }
.error-item { background: #0f172a; border: 1px solid #374151; border-radius: 6px; padding: 12px; margin-bottom: 10px; }
.error-item .path { font-family: monospace; color: #38bdf8; font-size: 13px; }
.error-item .module { color: #a78bfa; font-size: 12px; }
.error-item .detail { color: #fbbf24; font-size: 12px; margin-top: 6px; font-family: monospace; }
.error-item .http { display: inline-block; background: #7f1d1d; color: #fca5a5; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.passed-item { color: #4ade80; font-size: 13px; font-family: monospace; padding: 3px 0; }
.warning-item { color: #fbbf24; font-size: 13px; padding: 3px 0; }
.copy-box { background: #020617; border: 1px solid #334155; border-radius: 8px; padding: 15px; margin-top: 20px; position: relative; }
.copy-box pre { font-size: 12px; white-space: pre-wrap; word-break: break-all; color: #cbd5e1; max-height: 400px; overflow-y: auto; }
.copy-btn { position: absolute; top: 10px; left: 10px; background: #3b82f6; color: white; border: none; padding: 6px 14px; border-radius: 5px; cursor: pointer; font-size: 12px; }
.copy-btn:hover { background: #2563eb; }
.progress { color: #64748b; font-size: 13px; margin-bottom: 10px; }
</style></head><body><div class="container">';
echo '<h1>ERP Smoke Test</h1>';
echo '<p class="subtitle">Testing: ' . htmlspecialchars($baseUrl) . ' | ' . date('Y-m-d H:i:s') . '</p>';
if (!ob_get_level()) { ob_flush(); flush(); }
}
// ─── Step 1: Discover All Routes ────────────────────────────────────────────
output("═══════════════════════════════════════════════", 'header');
output(" ERP SMOKE TESTER", 'header');
......@@ -73,6 +117,11 @@ foreach ($routeFiles as $file) {
$results['total_routes'] = count($allRoutes);
output("Found " . count($allRoutes) . " routes across " . count($routeFiles) . " modules\n");
if (!$isCli) {
echo '<p class="progress">Found ' . count($allRoutes) . ' routes across ' . count($routeFiles) . ' modules. Testing...</p>';
if (!ob_get_level()) { ob_flush(); flush(); }
}
// ─── Step 2: Authenticate ───────────────────────────────────────────────────
output("Authenticating as {$adminEmail}...", 'info');
......@@ -93,7 +142,12 @@ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$loginPage || $httpCode >= 400) {
output("FATAL: Cannot reach login page (HTTP {$httpCode})", 'error');
$msg = "FATAL: Cannot reach login page (HTTP {$httpCode})";
output($msg, 'error');
if (!$isCli) {
echo '<p style="color:#f87171;font-weight:bold;">' . htmlspecialchars($msg) . '</p></div></body></html>';
return;
}
exit(1);
}
......@@ -136,6 +190,7 @@ output("Auth complete. Testing routes...\n", 'success');
// ─── Step 3: Test Each GET Route ────────────────────────────────────────────
$getRoutes = array_filter($allRoutes, fn($r) => $r['method'] === 'GET');
$tested = 0;
$passedRoutes = [];
foreach ($getRoutes as $route) {
if ($tested >= $maxPages) break;
......@@ -196,7 +251,7 @@ foreach ($getRoutes as $route) {
if (preg_match('/Stack trace:(.+?)(?:<\/pre>|<br|$)/s', $body, $stm)) {
$trace = trim(strip_tags($stm[1]));
$traceLines = array_filter(explode("\n", $trace));
$errorDetails[] = "Trace: " . implode(' ', array_slice($traceLines, 0, 3));
$errorDetails[] = "Trace: " . implode(' | ', array_slice($traceLines, 0, 3));
}
}
......@@ -208,6 +263,7 @@ foreach ($getRoutes as $route) {
if ($httpCode === 404) {
if (strpos($body, 'غير موجود') !== false || strpos($body, 'not found') !== false) {
$results['passed']++;
$passedRoutes[] = "[200~] {$testPath} (entity not found - OK)";
output(" ✓ [{$httpCode}] {$testPath} (entity not found - OK) [{$totalTime}s]", 'success');
continue;
}
......@@ -252,6 +308,7 @@ foreach ($getRoutes as $route) {
output(" ✗ [{$httpCode}] {$testPath} — " . implode(' | ', array_slice($errorDetails, 0, 2)), 'error');
} else {
$results['passed']++;
$passedRoutes[] = "[{$httpCode}] {$testPath} [{$totalTime}s]";
if ($httpCode >= 200 && $httpCode < 400) {
output(" ✓ [{$httpCode}] {$testPath} [{$totalTime}s]", 'success');
} else {
......@@ -264,68 +321,150 @@ $results['tested'] = $tested;
$results['duration'] = round(microtime(true) - $results['start_time'], 1);
// ─── Step 4: Output Report ──────────────────────────────────────────────────
output("\n═══════════════════════════════════════════════", 'header');
output(" SMOKE TEST REPORT", 'header');
output("═══════════════════════════════════════════════", 'header');
output("");
output("Total Routes: {$results['total_routes']}");
output("GET Tested: {$results['tested']}");
output("Passed: {$results['passed']}", 'success');
output("Failed: {$results['failed']}", $results['failed'] > 0 ? 'error' : 'success');
output("Skipped(403): {$results['skipped']}");
output("Duration: {$results['duration']}s");
output("");
if (!empty($results['errors'])) {
output("─── ERRORS (" . count($results['errors']) . ") ───────────────────────────────", 'error');
if ($isCli) {
output("\n═══════════════════════════════════════════════", 'header');
output(" SMOKE TEST REPORT", 'header');
output("═══════════════════════════════════════════════", 'header');
output("");
foreach ($results['errors'] as $i => $err) {
$num = $i + 1;
output(" [{$num}] {$err['module']} :: {$err['handler']}", 'error');
output(" Route: {$err['route']}");
output(" URL: {$err['url']} (HTTP {$err['http']})");
foreach ($err['details'] as $detail) {
output(" → {$detail}", 'warning');
output("Total Routes: {$results['total_routes']}");
output("GET Tested: {$results['tested']}");
output("Passed: {$results['passed']}", 'success');
output("Failed: {$results['failed']}", $results['failed'] > 0 ? 'error' : 'success');
output("Skipped(403): {$results['skipped']}");
output("Duration: " . $results['duration'] . "s");
output("");
if (!empty($results['errors'])) {
output("─── ERRORS (" . count($results['errors']) . ") ───────────────────────────────", 'error');
output("");
foreach ($results['errors'] as $i => $err) {
$num = $i + 1;
output(" [{$num}] {$err['module']} :: {$err['handler']}", 'error');
output(" Route: {$err['route']}");
output(" URL: {$err['url']} (HTTP {$err['http']})");
foreach ($err['details'] as $detail) {
output(" → {$detail}", 'warning');
}
output("");
}
}
if (!empty($results['warnings'])) {
output("─── WARNINGS (" . count($results['warnings']) . ") ─────────────────────────────", 'warning');
foreach ($results['warnings'] as $w) {
output(" ⚠ {$w}", 'warning');
}
output("");
}
}
if (!empty($results['warnings'])) {
output("─── WARNINGS (" . count($results['warnings']) . ") ─────────────────────────────", 'warning');
foreach ($results['warnings'] as $w) {
output(" ⚠ {$w}", 'warning');
// Compact output for Claude
if (!empty($results['errors'])) {
output("\n─── COPY BELOW TO CLAUDE ────────────────────────", 'header');
output("```");
output("SMOKE TEST: {$results['failed']} errors / {$results['passed']} passed / {$results['tested']} tested");
foreach ($results['errors'] as $i => $err) {
$num = $i + 1;
output("[{$num}] {$err['module']}::{$err['handler']} HTTP={$err['http']} PATH={$err['url']}");
foreach ($err['details'] as $detail) {
output(" {$detail}");
}
}
if (!empty($results['warnings'])) {
output("WARNINGS:");
foreach ($results['warnings'] as $w) {
output(" {$w}");
}
}
output("```");
output("─── END COPY ───────────────────────────────────", 'header');
}
} else {
// ─── Browser HTML Report ────────────────────────────────────────────────
echo '<div class="stats">';
echo '<div class="stat total"><div class="num">' . $results['total_routes'] . '</div><div class="label">Total Routes</div></div>';
echo '<div class="stat total"><div class="num">' . $results['tested'] . '</div><div class="label">Tested</div></div>';
echo '<div class="stat pass"><div class="num">' . $results['passed'] . '</div><div class="label">Passed</div></div>';
echo '<div class="stat fail"><div class="num">' . $results['failed'] . '</div><div class="label">Failed</div></div>';
echo '<div class="stat skip"><div class="num">' . $results['skipped'] . '</div><div class="label">Skipped (403)</div></div>';
echo '<div class="stat"><div class="num">' . $results['duration'] . 's</div><div class="label">Duration</div></div>';
echo '</div>';
// Errors section
if (!empty($results['errors'])) {
echo '<div class="section"><h2>Errors (' . count($results['errors']) . ')</h2>';
foreach ($results['errors'] as $i => $err) {
echo '<div class="error-item">';
echo '<span class="http">HTTP ' . $err['http'] . '</span> ';
echo '<span class="module">' . htmlspecialchars($err['module']) . ' :: ' . htmlspecialchars($err['handler']) . '</span><br>';
echo '<span class="path">' . htmlspecialchars($err['url']) . '</span>';
foreach ($err['details'] as $detail) {
echo '<div class="detail">' . htmlspecialchars($detail) . '</div>';
}
echo '</div>';
}
echo '</div>';
}
output("");
}
// ─── Save JSON report ───────────────────────────────────────────────────────
$reportFile = __DIR__ . '/smoke_test_report.json';
file_put_contents($reportFile, json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
output("Full report saved to: {$reportFile}");
// Warnings section
if (!empty($results['warnings'])) {
echo '<div class="section"><h2 style="color:#fbbf24;">Warnings (' . count($results['warnings']) . ')</h2>';
foreach ($results['warnings'] as $w) {
echo '<div class="warning-item">' . htmlspecialchars($w) . '</div>';
}
echo '</div>';
}
// Passed routes (collapsible)
if (!empty($passedRoutes)) {
echo '<details style="margin-bottom:15px;"><summary style="cursor:pointer;color:#4ade80;padding:10px;background:#1e293b;border-radius:8px;border:1px solid #334155;">Passed Routes (' . count($passedRoutes) . ') — click to expand</summary>';
echo '<div class="section" style="margin-top:5px;">';
foreach ($passedRoutes as $pr) {
echo '<div class="passed-item">' . htmlspecialchars($pr) . '</div>';
}
echo '</div></details>';
}
// ─── Compact output for Claude ──────────────────────────────────────────────
if (!empty($results['errors'])) {
output("\n─── COPY BELOW TO CLAUDE ────────────────────────", 'header');
output("```");
output("SMOKE TEST: {$results['failed']} errors / {$results['passed']} passed / {$results['tested']} tested");
// Copy-to-Claude box
$claudeText = "SMOKE TEST: {$results['failed']} errors / {$results['passed']} passed / {$results['tested']} tested\n";
foreach ($results['errors'] as $i => $err) {
$num = $i + 1;
output("[{$num}] {$err['module']}::{$err['handler']} HTTP={$err['http']} PATH={$err['url']}");
$claudeText .= "[{$num}] {$err['module']}::{$err['handler']} HTTP={$err['http']} PATH={$err['url']}\n";
foreach ($err['details'] as $detail) {
output(" {$detail}");
$claudeText .= " {$detail}\n";
}
}
if (!empty($results['warnings'])) {
output("WARNINGS:");
$claudeText .= "WARNINGS:\n";
foreach ($results['warnings'] as $w) {
output(" {$w}");
$claudeText .= " {$w}\n";
}
}
output("```");
output("─── END COPY ───────────────────────────────────", 'header');
echo '<div class="copy-box">';
echo '<button class="copy-btn" onclick="copyReport()">Copy for Claude</button>';
echo '<pre id="claude-report">' . htmlspecialchars($claudeText) . '</pre>';
echo '</div>';
echo '<script>
function copyReport() {
const text = document.getElementById("claude-report").textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector(".copy-btn");
btn.textContent = "Copied!";
btn.style.background = "#059669";
setTimeout(() => { btn.textContent = "Copy for Claude"; btn.style.background = "#3b82f6"; }, 2000);
});
}
</script>';
echo '</div></body></html>';
}
// ─── Save JSON report ───────────────────────────────────────────────────────
$reportFile = __DIR__ . '/smoke_test_report.json';
file_put_contents($reportFile, json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
if ($isCli) output("Full report saved to: {$reportFile}");
@unlink($cookieFile);
exit($results['failed'] > 0 ? 1 : 0);
if ($isCli && !defined('SMOKE_TEST_INCLUDED')) exit($results['failed'] > 0 ? 1 : 0);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment