#!/usr/bin/env npx tsx import { mkdirSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; import { getPage } from './browse.js'; type NavResult = { requestedUrl: string; url: string; status: number | null; title: string; error?: string; }; type RouteCheck = { route: string; result: NavResult; heading: string | null; }; const DEFAULT_BASE_URL = 'http://localhost:3000'; const DEFAULT_REPORT_PATH = resolve(process.cwd(), 'scan-local-app.md'); function env(name: string): string | undefined { const value = process.env[name]?.trim(); return value ? value : undefined; } function getRoutes(baseUrl: string): string[] { const routeList = env('SCAN_ROUTES'); if (routeList) { return routeList .split(',') .map((route) => route.trim()) .filter(Boolean) .map((route) => new URL(route, baseUrl).toString()); } return [baseUrl]; } async function gotoWithStatus(page: any, url: string): Promise { const response = await page .goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) .catch((error: unknown) => ({ error })); if (response?.error) { return { requestedUrl: url, url: page.url(), status: null, title: await page.title().catch(() => ''), error: String(response.error), }; } return { requestedUrl: url, url: page.url(), status: response ? response.status() : null, title: await page.title().catch(() => ''), }; } async function textOrNull(page: any, selector: string): Promise { const locator = page.locator(selector).first(); try { if ((await locator.count()) === 0) return null; const value = await locator.textContent(); return value ? value.trim().replace(/\s+/g, ' ') : null; } catch { return null; } } async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { const loginPath = env('SCAN_LOGIN_PATH'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); const usernameSelector = env('SCAN_USERNAME_SELECTOR') ?? 'input[type="email"], input[name="email"]'; const passwordSelector = env('SCAN_PASSWORD_SELECTOR') ?? 'input[type="password"], input[name="password"]'; const submitSelector = env('SCAN_SUBMIT_SELECTOR') ?? 'button[type="submit"], input[type="submit"]'; if (!loginPath) { lines.push('## Login'); lines.push('- Skipped: set `SCAN_LOGIN_PATH` to enable login smoke checks.'); lines.push(''); return; } const loginUrl = new URL(loginPath, baseUrl).toString(); lines.push('## Login'); lines.push(`- Login URL: ${loginUrl}`); await gotoWithStatus(page, loginUrl); if (!username || !password) { lines.push('- Skipped: set `SCAN_USERNAME`/`SCAN_PASSWORD` or `CLOAKBROWSER_USERNAME`/`CLOAKBROWSER_PASSWORD`.'); lines.push(''); return; } await page.locator(usernameSelector).first().fill(username); await page.locator(passwordSelector).first().fill(password); await page.locator(submitSelector).first().click(); await page.waitForTimeout(2500); lines.push(`- After submit URL: ${page.url()}`); lines.push(`- Cookie count: ${(await page.context().cookies()).length}`); lines.push(''); } async function checkRoutes(page: any, baseUrl: string, lines: string[]) { const routes = getRoutes(baseUrl); const routeChecks: RouteCheck[] = []; for (const url of routes) { const result = await gotoWithStatus(page, url); const heading = await textOrNull(page, 'h1'); routeChecks.push({ route: url, result, heading, }); } lines.push('## Route Checks'); for (const check of routeChecks) { const relativeUrl = check.route.startsWith(baseUrl) ? check.route.slice(baseUrl.length) || '/' : check.route; const finalPath = check.result.url.startsWith(baseUrl) ? check.result.url.slice(baseUrl.length) || '/' : check.result.url; const suffix = check.heading ? `, h1="${check.heading}"` : ''; const errorSuffix = check.result.error ? `, error="${check.result.error}"` : ''; lines.push( `- ${relativeUrl} → status ${check.result.status ?? 'ERR'} (final ${finalPath})${suffix}${errorSuffix}` ); } lines.push(''); } async function main() { const baseUrl = env('SCAN_BASE_URL') ?? DEFAULT_BASE_URL; const reportPath = resolve(env('SCAN_REPORT_PATH') ?? DEFAULT_REPORT_PATH); const headless = (env('SCAN_HEADLESS') ?? env('CLOAKBROWSER_HEADLESS') ?? 'true') === 'true'; const { page, browser } = await getPage({ headless }); const lines: string[] = []; lines.push('# Web Automation Scan (local)'); lines.push(''); lines.push(`- Base URL: ${baseUrl}`); lines.push(`- Timestamp: ${new Date().toISOString()}`); lines.push(`- Headless: ${headless}`); lines.push(`- Report Path: ${reportPath}`); lines.push(''); try { await loginIfConfigured(page, baseUrl, lines); await checkRoutes(page, baseUrl, lines); lines.push('## Notes'); lines.push('- This generic smoke helper records route availability and top-level headings for a local app.'); lines.push('- Configure login and route coverage with `SCAN_*` environment variables.'); } finally { await browser.close(); } mkdirSync(dirname(reportPath), { recursive: true }); writeFileSync(reportPath, `${lines.join('\n')}\n`, 'utf-8'); console.log(`Report written to ${reportPath}`); } main().catch((error) => { console.error(error); process.exitCode = 1; });