/** * Route crawler: logs in via Playwright, visits all app routes, * reports HTTP errors and JS/Blade errors in the page body. * * Usage: node scripts/crawl-all-routes.mjs */ // Self-re-exec with playwright system deps on Linux if needed if (process.platform === 'linux' && !process.env.__PW_ENV_OK) { const { existsSync } = await import('fs'); const depsDir = `${process.env.HOME}/.local/lib/playwright-deps`; if (existsSync(depsDir)) { const { spawnSync } = await import('child_process'); const ld = process.env.LD_LIBRARY_PATH ?? ''; const result = spawnSync(process.execPath, process.argv.slice(1), { env: { ...process.env, LD_LIBRARY_PATH: ld ? `${depsDir}:${ld}` : depsDir, __PW_ENV_OK: '1' }, stdio: 'inherit', }); process.exit(result.status ?? 1); } } import { chromium } from '@playwright/test'; import { spawnSync } from 'child_process'; import { readFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dir = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(__dir, '..'); // Read .env for DB creds (dev-only script — .env never committed) function readEnv() { try { return Object.fromEntries( readFileSync(resolve(projectRoot, '.env'), 'utf8') .split('\n') .filter(l => l && !l.startsWith('#') && l.includes('=')) .map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; }) ); } catch { return {}; } } const env = readEnv(); const BASE = process.env.APP_URL ?? env.APP_URL ?? 'https://app.nimuli.com'; const EMAIL = process.env.TEST_EMAIL ?? 'nexxo@nimuli.com'; const PASS = process.env.TEST_PASSWORD ?? 'NimuliDev2026!'; const DB_USER = process.env.DB_USERNAME ?? env.DB_USERNAME ?? 'nimuli'; const DB_PASS = process.env.DB_PASSWORD ?? env.DB_PASSWORD ?? 'nimuli'; const DB_NAME = process.env.DB_DATABASE ?? env.DB_DATABASE ?? 'nimuli'; let pass = 0; let fail = 0; const failures = []; console.log(`\n${'═'.repeat(47)}`); console.log(' Nimuli — Route Crawler'); console.log(` ${new Date().toISOString()}`); console.log(` Base: ${BASE}`); console.log(`${'═'.repeat(47)}\n`); // ── DB helper (safe: no shell, all args explicit) ─ function dbQuery(sql) { const result = spawnSync( 'docker', ['compose', 'exec', '-T', 'mysql', 'mysql', `-u${DB_USER}`, `-p${DB_PASS}`, DB_NAME, '-se', sql], { encoding: 'utf8', cwd: projectRoot } ); if (result.status !== 0) return ''; const lines = result.stdout.trim().split('\n').filter(Boolean); return lines[lines.length - 1]?.trim() ?? ''; } // ── Get workspace ULID ──────────────────────── const wsUlid = dbQuery( "SELECT w.ulid FROM workspaces w JOIN workspace_members wm ON wm.workspace_id=w.id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' ORDER BY w.created_at LIMIT 1" ); if (!wsUlid) { console.error(' ✗ No workspace for nexxo@nimuli.com'); process.exit(1); } console.log(` Workspace: ${wsUlid}\n`); // ── Get sample resource IDs ─────────────────── const linkUlid = dbQuery( "SELECT l.ulid FROM links l JOIN workspace_members wm ON wm.workspace_id=l.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1" ); const qrUlid = dbQuery( "SELECT q.ulid FROM qr_codes q JOIN workspace_members wm ON wm.workspace_id=q.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1" ); const bioSlug = dbQuery( "SELECT bp.slug FROM bio_pages bp JOIN workspace_members wm ON wm.workspace_id=bp.workspace_id JOIN users u ON u.id=wm.user_id WHERE u.email='nexxo@nimuli.com' LIMIT 1" ); // Detect if HTTPS crawling works; fall back to HTTP-via-localhost const HTTP_BASE = 'http://localhost'; const APP_HOST = new URL(BASE).hostname; // e.g. app.nimuli.com const browser = await chromium.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--in-process-gpu', '--single-process', '--no-zygote', '--disable-features=VizDisplayCompositor', ], }); const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 900 } }); // Route all HTTPS app.nimuli.com requests to HTTP to avoid TLS renderer crash. // Vite dev-server assets (@vite, resources/js) → localhost:5173 // Everything else → localhost:80 // Uses route.fetch (Node-side, no TLS) + route.fulfill to serve response to browser. await ctx.route(`https://${APP_HOST}/**`, async route => { const u = new URL(route.request().url()); if (u.pathname.startsWith('/@vite') || u.pathname.startsWith('/resources/js') || u.pathname.startsWith('/@id') || u.pathname.startsWith('/@fs')) { u.protocol = 'http:'; u.hostname = 'localhost'; u.port = '5173'; } else { u.protocol = 'http:'; u.hostname = 'localhost'; u.port = ''; } try { const response = await route.fetch({ url: u.toString() }); await route.fulfill({ response }); } catch { await route.abort(); } }); const page = await ctx.newPage(); // Collect JS errors per-page const pageErrors = []; page.on('console', msg => { if (msg.type() === 'error') pageErrors.push(msg.text()); }); page.on('pageerror', err => { pageErrors.push(`PageError: ${err.message}`); }); // ── Login ───────────────────────────────────── console.log('[ LOGIN ]'); await page.goto(`${HTTP_BASE}/login`, { waitUntil: 'load' }); await page.fill('input[name=email]', EMAIL); await page.fill('input[name=password]', PASS); await page.click('button[type=submit]'); await page.waitForLoadState('load'); if (page.url().includes('/login')) { console.error(' ✗ Still on login page — wrong credentials or app error'); await browser.close(); process.exit(1); } console.log(` ✓ Authenticated: ${page.url()}\n`); // ── Route check helper ──────────────────────── const BLADE_ERR_RE = /Undefined variable|ErrorException|Whoops|Class .* not found|Call to .* on null|syntax error/i; async function check(path, label) { pageErrors.length = 0; const resp = await page.goto(`${HTTP_BASE}/${path}`, { waitUntil: 'load' }); const status = resp?.status() ?? 0; const body = await page.content(); const bladeErr = BLADE_ERR_RE.test(body) ? (body.match(/(?:Undefined variable \w+|ErrorException.{0,60}|Class .{0,30} not found)/)?.[0] ?? 'Blade error') : null; const consoleErrs = [...pageErrors]; if (status >= 400 || bladeErr || consoleErrs.length > 0) { let reason = status >= 400 ? `HTTP ${status}` : ''; if (bladeErr) reason += (reason ? ', ' : '') + bladeErr; if (consoleErrs.length) reason += (reason ? ', ' : '') + `${consoleErrs.length} JS error(s): ${consoleErrs[0].slice(0, 80)}`; console.error(` ✗ ${label} → ${reason}`); failures.push({ path, label, reason }); fail++; } else { console.log(` ✓ ${label} → ${status}`); pass++; } } // ── Static routes ───────────────────────────── console.log('[ STATIC ROUTES ]'); await check('', 'root /'); await check('forgot-password', 'forgot-password'); // ── Profile routes ──────────────────────────── console.log('\n[ PROFILE ROUTES ]'); await check('profile', 'profile'); await check('profile/personal', 'profile/personal'); await check('profile/preferences', 'profile/preferences'); await check('profile/security', 'profile/security'); // ── Workspace routes ────────────────────────── console.log(`\n[ WORKSPACE ROUTES: ${wsUlid} ]`); await check(`w/${wsUlid}`, 'dashboard'); await check(`w/${wsUlid}/links`, 'links'); await check(`w/${wsUlid}/qr`, 'qr-codes'); await check(`w/${wsUlid}/bio`, 'bio-pages'); await check(`w/${wsUlid}/analytics`, 'analytics'); await check(`w/${wsUlid}/domains`, 'domains'); await check(`w/${wsUlid}/team`, 'team'); await check(`w/${wsUlid}/billing`, 'billing'); await check(`w/${wsUlid}/settings`, 'settings'); await check(`w/${wsUlid}/settings/api-tokens`, 'settings/api-tokens'); await check(`w/${wsUlid}/settings/webhooks`, 'settings/webhooks'); // ── AI routes ───────────────────────────────── console.log('\n[ AI ROUTES ]'); await check(`w/${wsUlid}/ai/insights`, 'ai/insights'); await check(`w/${wsUlid}/ai/anomalies`, 'ai/anomalies'); await check(`w/${wsUlid}/ai/ab-generator`, 'ai/ab-generator'); // ── Resource routes ─────────────────────────── console.log('\n[ RESOURCE ROUTES ]'); if (linkUlid) await check(`w/${wsUlid}/links/${linkUlid}`, 'link show'); else console.log(' — no links in DB, skipping'); if (qrUlid) await check(`w/${wsUlid}/qr/${qrUlid}`, 'qr show'); else console.log(' — no QR codes in DB, skipping'); if (bioSlug) await check(`bio/${bioSlug}`, 'bio public'); else console.log(' — no bio pages in DB, skipping'); await browser.close(); // ── Summary ─────────────────────────────────── const total = pass + fail; console.log(`\n${'═'.repeat(47)}`); console.log(` Passed: ${pass} / ${total}`); if (fail > 0) { console.error(` Failed: ${fail}`); console.error('\n Failures:'); for (const f of failures) console.error(` - ${f.label}: ${f.reason}`); console.error(`${'═'.repeat(47)}\n`); process.exit(1); } console.log(' ✅ All routes OK'); console.log(`${'═'.repeat(47)}\n`);