181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
#!/usr/bin/env npx tsx
|
||
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||
|
||
import { mkdirSync, writeFileSync } from 'fs';
|
||
import { dirname, resolve } from 'path';
|
||
import type { Page } from 'playwright-core';
|
||
import { getPage } from './lib/browser.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];
|
||
}
|
||
|
||
type GotoError = { error: unknown };
|
||
|
||
async function gotoWithStatus(page: Page, url: string): Promise<NavResult> {
|
||
const response = await page
|
||
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||
.catch((error: unknown): GotoError => ({ error }));
|
||
|
||
if (response !== null && response !== undefined && 'error' in response) {
|
||
const gotoError = response as GotoError;
|
||
return {
|
||
requestedUrl: url,
|
||
url: page.url(),
|
||
status: null,
|
||
title: await page.title().catch(() => ''),
|
||
error: String(gotoError.error),
|
||
};
|
||
}
|
||
|
||
const httpResponse = response as Awaited<ReturnType<Page['goto']>>;
|
||
return {
|
||
requestedUrl: url,
|
||
url: page.url(),
|
||
status: httpResponse ? httpResponse.status() : null,
|
||
title: await page.title().catch(() => ''),
|
||
};
|
||
}
|
||
|
||
async function textOrNull(page: Page, selector: string): Promise<string | null> {
|
||
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: Page, 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: Page, 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;
|
||
});
|