229 lines
9.8 KiB
JavaScript
229 lines
9.8 KiB
JavaScript
/**
|
|
* 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`);
|