feat(web-automation): implement milestone M2 mirror and docs
This commit is contained in:
@@ -1,12 +1,9 @@
|
||||
import { writeFileSync } from 'fs';
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { getPage } from './browse.js';
|
||||
|
||||
const baseUrl = 'http://localhost:3000';
|
||||
const username = 'analyst@fhb.local';
|
||||
const password = process.env.CAMOUFOX_PASSWORD ?? '';
|
||||
|
||||
const reportPath = '/Users/stefano.fiorini/Documents/projects/fhb-loan-spreading-pilot-a/docs/plans/2026-01-24-financials-analysis-redesign/web-automation-scan.md';
|
||||
|
||||
type NavResult = {
|
||||
requestedUrl: string;
|
||||
url: string;
|
||||
@@ -15,198 +12,163 @@ type NavResult = {
|
||||
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<NavResult> {
|
||||
const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }).catch((e: unknown) => ({ error: e }));
|
||||
if (resp?.error) {
|
||||
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(resp.error),
|
||||
error: String(response.error),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requestedUrl: url,
|
||||
url: page.url(),
|
||||
status: resp ? resp.status() : null,
|
||||
status: response ? response.status() : null,
|
||||
title: await page.title().catch(() => ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function textOrNull(page: any, selector: string): Promise<string | null> {
|
||||
const loc = page.locator(selector).first();
|
||||
const locator = page.locator(selector).first();
|
||||
try {
|
||||
if ((await loc.count()) === 0) return null;
|
||||
const txt = await loc.textContent();
|
||||
return txt ? txt.trim().replace(/\s+/g, ' ') : null;
|
||||
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 { page, browser } = await getPage({ headless: true });
|
||||
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 {
|
||||
lines.push('## Login');
|
||||
await gotoWithStatus(page, `${baseUrl}/login`);
|
||||
await page.locator('input[name="email"]').fill(username);
|
||||
await page.locator('input[name="password"]').fill(password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const cookies = await page.context().cookies();
|
||||
const sessionCookie = cookies.find((c: any) => c.name === 'fhb_session');
|
||||
lines.push(`- After submit URL: ${page.url()}`);
|
||||
lines.push(`- Has session cookie (fhb_session): ${Boolean(sessionCookie)}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Demo Case');
|
||||
const casesNav = await gotoWithStatus(page, `${baseUrl}/cases`);
|
||||
lines.push(`- GET /cases → status ${casesNav.status ?? 'ERR'}, final ${casesNav.url}`);
|
||||
|
||||
const envCaseId = process.env.SCAN_CASE_ID?.trim() || null;
|
||||
let selectedCaseId: string | null = envCaseId;
|
||||
|
||||
if (!selectedCaseId) {
|
||||
const caseLinks = await page.$$eval('a[href^="/cases/"]', (as) =>
|
||||
as
|
||||
.map((a) => ({
|
||||
href: (a as HTMLAnchorElement).getAttribute('href') || '',
|
||||
text: (a.textContent || '').trim(),
|
||||
}))
|
||||
.filter((x) => x.href.includes('/cases/'))
|
||||
);
|
||||
|
||||
const preferredTitles = ['Demo - Strong Borrower', 'Demo - Weak Borrower', 'Demo - Incomplete'];
|
||||
for (const title of preferredTitles) {
|
||||
const match = caseLinks.find((l) => l.text.includes(title) && l.href.includes('/cases/'));
|
||||
const href = match?.href ?? '';
|
||||
const m = href.match(/\/cases\/([0-9a-f-]{36})/i);
|
||||
if (m) {
|
||||
selectedCaseId = m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedCaseId) {
|
||||
const firstHref =
|
||||
caseLinks.map((l) => l.href).find((h) => /\/cases\/[0-9a-f-]{36}/i.test(h)) ?? null;
|
||||
const m = firstHref?.match(/\/cases\/([0-9a-f-]{36})/i) ?? null;
|
||||
selectedCaseId = m?.[1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`- Selected caseId: ${selectedCaseId ?? '(none found)'}`);
|
||||
|
||||
if (!selectedCaseId) {
|
||||
lines.push('');
|
||||
lines.push('⚠️ Could not find a demo case link on /cases.');
|
||||
writeFileSync(reportPath, lines.join('\n') + '\n', 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
const caseBase = `${baseUrl}/cases/${selectedCaseId}/journey`;
|
||||
|
||||
lines.push('');
|
||||
lines.push('## Route Checks');
|
||||
|
||||
const routesToCheck = [
|
||||
`${caseBase}`,
|
||||
`${caseBase}/financials`,
|
||||
`${caseBase}/financials/income`,
|
||||
`${caseBase}/analysis`,
|
||||
`${caseBase}/analysis/configure`,
|
||||
`${caseBase}/analysis/ai`,
|
||||
`${caseBase}/analysis/ai/detail`,
|
||||
`${caseBase}/spreads`,
|
||||
];
|
||||
|
||||
for (const url of routesToCheck) {
|
||||
const r = await gotoWithStatus(page, url);
|
||||
const h1 = await textOrNull(page, 'h1');
|
||||
const finalPath = r.url.startsWith(baseUrl) ? r.url.slice(baseUrl.length) : r.url;
|
||||
lines.push(`- ${url.slice(baseUrl.length)} → status ${r.status ?? 'ERR'} (final ${finalPath})${h1 ? `, h1="${h1}"` : ''}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('## Spreadsheet Analysis (UI)');
|
||||
await gotoWithStatus(page, `${caseBase}/analysis/configure`);
|
||||
|
||||
const runButton = page.locator('button:has-text("Run Analysis")').first();
|
||||
const disabled = await runButton.isDisabled().catch(() => true);
|
||||
lines.push(`- Run button disabled: ${disabled}`);
|
||||
|
||||
if (!disabled) {
|
||||
await runButton.click();
|
||||
|
||||
const resultsWait = page
|
||||
.waitForURL('**/journey/analysis/results**', { timeout: 180000 })
|
||||
.then(() => 'results' as const);
|
||||
const errorWait = page
|
||||
.locator('[role="alert"]')
|
||||
.filter({ hasText: 'Error' })
|
||||
.first()
|
||||
.waitFor({ timeout: 180000 })
|
||||
.then(() => 'error' as const);
|
||||
|
||||
const outcome = await Promise.race([resultsWait, errorWait]).catch(() => 'timeout' as const);
|
||||
|
||||
if (outcome === 'results') {
|
||||
await page.waitForTimeout(1500);
|
||||
lines.push(`- Results URL: ${page.url().replace(baseUrl, '')}`);
|
||||
|
||||
const downloadHref = await page
|
||||
.locator('a[href*="/journey/analysis/download"]')
|
||||
.first()
|
||||
.getAttribute('href')
|
||||
.catch(() => null);
|
||||
|
||||
if (downloadHref) {
|
||||
const dlUrl = downloadHref.startsWith('http') ? downloadHref : `${baseUrl}${downloadHref}`;
|
||||
const dlResp = await page.goto(dlUrl, { waitUntil: 'commit', timeout: 60000 }).catch(() => null);
|
||||
lines.push(
|
||||
`- Download route status: ${dlResp?.status() ?? 'ERR'} (Content-Type: ${dlResp?.headers()?.['content-type'] ?? 'n/a'})`
|
||||
);
|
||||
} else {
|
||||
lines.push('- Download link not found on results page');
|
||||
}
|
||||
} else if (outcome === 'error') {
|
||||
const errorText = await page
|
||||
.locator('[role="alert"]')
|
||||
.first()
|
||||
.textContent()
|
||||
.then((t: string | null) => (t ? t.trim().replace(/\\s+/g, ' ') : null))
|
||||
.catch(() => null);
|
||||
lines.push(`- Stayed on configure page; saw error callout: ${errorText ?? '(unable to read)'}`);
|
||||
lines.push('- Skipping download check because analysis did not complete.');
|
||||
} else {
|
||||
lines.push('- Timed out waiting for results or error after clicking Run Analysis.');
|
||||
}
|
||||
} else {
|
||||
lines.push('- Skipped running analysis because Run button was disabled.');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
await loginIfConfigured(page, baseUrl, lines);
|
||||
await checkRoutes(page, baseUrl, lines);
|
||||
lines.push('## Notes');
|
||||
lines.push('- This scan avoids scraping financial values; it records route availability and basic headings.');
|
||||
|
||||
writeFileSync(reportPath, lines.join('\n') + '\n', 'utf-8');
|
||||
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((err) => {
|
||||
console.error(err);
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user