Rename plugins to automations and fix scheduler/payment bugs
Major changes: - Rename "plugins" to "automations" throughout codebase - Move automation system to dedicated app (scheduling/automations/) - Add new automation marketplace, creation, and management pages Bug fixes: - Fix payment endpoint 500 errors (use has_feature() instead of attribute) - Fix scheduler showing "0 AM" instead of "12 AM" - Fix scheduler business hours double-inversion display issue - Fix scheduler scroll-to-current-time when switching views - Fix week view centering on wrong day (use Sunday-based indexing) - Fix capacity widget overflow with many resources - Fix Recharts minWidth/minHeight console warnings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
232
frontend/tests/e2e/site-crawler.spec.ts
Normal file
232
frontend/tests/e2e/site-crawler.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Site Crawler Tests
|
||||
*
|
||||
* Crawls the site looking for broken links, console errors, and network failures.
|
||||
* Run with: npx playwright test site-crawler.spec.ts
|
||||
*
|
||||
* These tests are designed to be run in CI to catch issues before deployment.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
SiteCrawler,
|
||||
formatReport,
|
||||
loginAsUser,
|
||||
TEST_USERS,
|
||||
CrawlerOptions,
|
||||
} from './utils/crawler';
|
||||
|
||||
// Increase timeout for crawl tests since they visit many pages
|
||||
test.setTimeout(300000); // 5 minutes
|
||||
|
||||
const CRAWLER_OPTIONS: CrawlerOptions = {
|
||||
maxPages: 30, // Limit for CI to keep tests reasonable
|
||||
verbose: false,
|
||||
screenshotOnError: true,
|
||||
screenshotDir: 'test-results/crawler-screenshots',
|
||||
timeout: 15000,
|
||||
waitForNetworkIdle: true,
|
||||
excludePatterns: [
|
||||
/\.(pdf|zip|tar|gz|exe|dmg|pkg)$/i,
|
||||
/^mailto:/i,
|
||||
/^tel:/i,
|
||||
/^javascript:/i,
|
||||
/logout/i,
|
||||
/sign-out/i,
|
||||
// Exclude external links for faster tests
|
||||
/^https?:\/\/(?!.*lvh\.me)/i,
|
||||
],
|
||||
};
|
||||
|
||||
test.describe('Site Crawler - Public Pages', () => {
|
||||
test('should crawl public marketing site without errors', async ({ page, context }) => {
|
||||
const crawler = new SiteCrawler(page, context, {
|
||||
...CRAWLER_OPTIONS,
|
||||
maxPages: 20, // Fewer pages for public site
|
||||
});
|
||||
|
||||
const report = await crawler.crawl('http://lvh.me:5173');
|
||||
|
||||
// Print the report
|
||||
console.log(formatReport(report));
|
||||
|
||||
// Assertions
|
||||
expect(report.totalPages).toBeGreaterThan(0);
|
||||
|
||||
// Filter out minor warnings that aren't real issues
|
||||
const criticalErrors = report.results.flatMap(r =>
|
||||
r.errors.filter(e => {
|
||||
// Ignore React DevTools suggestion
|
||||
if (e.message.includes('React DevTools')) return false;
|
||||
// Ignore favicon 404
|
||||
if (e.message.includes('favicon.ico')) return false;
|
||||
// Ignore hot module replacement messages
|
||||
if (e.message.includes('hot-update') || e.message.includes('hmr')) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
// Fail if there are critical errors
|
||||
if (criticalErrors.length > 0) {
|
||||
console.error('\n❌ Critical errors found:');
|
||||
criticalErrors.forEach(e => {
|
||||
console.error(` - [${e.type}] ${e.url}: ${e.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(criticalErrors.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Site Crawler - Platform Dashboard', () => {
|
||||
test('should crawl platform dashboard without errors', async ({ page, context }) => {
|
||||
// Login first
|
||||
const user = TEST_USERS.platformSuperuser;
|
||||
const loggedIn = await loginAsUser(page, user);
|
||||
expect(loggedIn).toBe(true);
|
||||
|
||||
const crawler = new SiteCrawler(page, context, CRAWLER_OPTIONS);
|
||||
const report = await crawler.crawl('http://platform.lvh.me:5173');
|
||||
|
||||
// Print the report
|
||||
console.log(formatReport(report));
|
||||
|
||||
// Assertions
|
||||
expect(report.totalPages).toBeGreaterThan(0);
|
||||
|
||||
// Filter out minor warnings
|
||||
const criticalErrors = report.results.flatMap(r =>
|
||||
r.errors.filter(e => {
|
||||
if (e.message.includes('React DevTools')) return false;
|
||||
if (e.message.includes('favicon.ico')) return false;
|
||||
if (e.message.includes('hot-update') || e.message.includes('hmr')) return false;
|
||||
// Ignore WebSocket connection issues in dev
|
||||
if (e.message.includes('WebSocket')) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
if (criticalErrors.length > 0) {
|
||||
console.error('\n❌ Critical errors found:');
|
||||
criticalErrors.forEach(e => {
|
||||
console.error(` - [${e.type}] ${e.url}: ${e.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(criticalErrors.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Uncomment and configure when tenant test users are set up
|
||||
// test.describe('Site Crawler - Tenant Dashboard', () => {
|
||||
// test('should crawl tenant dashboard without errors', async ({ page, context }) => {
|
||||
// const user = TEST_USERS.businessOwner;
|
||||
// const loggedIn = await loginAsUser(page, user);
|
||||
// expect(loggedIn).toBe(true);
|
||||
//
|
||||
// const crawler = new SiteCrawler(page, context, CRAWLER_OPTIONS);
|
||||
// const report = await crawler.crawl('http://demo.lvh.me:5173');
|
||||
//
|
||||
// console.log(formatReport(report));
|
||||
//
|
||||
// expect(report.totalPages).toBeGreaterThan(0);
|
||||
//
|
||||
// const criticalErrors = report.results.flatMap(r =>
|
||||
// r.errors.filter(e => {
|
||||
// if (e.message.includes('React DevTools')) return false;
|
||||
// if (e.message.includes('favicon.ico')) return false;
|
||||
// if (e.message.includes('hot-update')) return false;
|
||||
// if (e.message.includes('WebSocket')) return false;
|
||||
// return true;
|
||||
// })
|
||||
// );
|
||||
//
|
||||
// expect(criticalErrors.length).toBe(0);
|
||||
// });
|
||||
// });
|
||||
|
||||
// test.describe('Site Crawler - Customer Portal', () => {
|
||||
// test('should crawl customer booking portal without errors', async ({ page, context }) => {
|
||||
// // Customer portal might not require auth for the booking pages
|
||||
// const crawler = new SiteCrawler(page, context, {
|
||||
// ...CRAWLER_OPTIONS,
|
||||
// maxPages: 15,
|
||||
// });
|
||||
//
|
||||
// const report = await crawler.crawl('http://demo.lvh.me:5173/book');
|
||||
//
|
||||
// console.log(formatReport(report));
|
||||
//
|
||||
// expect(report.totalPages).toBeGreaterThan(0);
|
||||
//
|
||||
// const criticalErrors = report.results.flatMap(r =>
|
||||
// r.errors.filter(e => {
|
||||
// if (e.message.includes('React DevTools')) return false;
|
||||
// if (e.message.includes('favicon.ico')) return false;
|
||||
// if (e.message.includes('hot-update')) return false;
|
||||
// return true;
|
||||
// })
|
||||
// );
|
||||
//
|
||||
// expect(criticalErrors.length).toBe(0);
|
||||
// });
|
||||
// });
|
||||
|
||||
// Quick smoke test that just checks key pages load
|
||||
test.describe('Site Crawler - Quick Smoke Test', () => {
|
||||
const keyPages = [
|
||||
{ name: 'Home', url: 'http://lvh.me:5173/' },
|
||||
{ name: 'About', url: 'http://lvh.me:5173/about' },
|
||||
{ name: 'Pricing', url: 'http://lvh.me:5173/pricing' },
|
||||
{ name: 'Privacy', url: 'http://lvh.me:5173/privacy' },
|
||||
{ name: 'Terms', url: 'http://lvh.me:5173/terms' },
|
||||
{ name: 'Contact', url: 'http://lvh.me:5173/contact' },
|
||||
];
|
||||
|
||||
for (const { name, url } of keyPages) {
|
||||
test(`${name} page should load without errors`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Capture console errors
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
const text = msg.text();
|
||||
// Filter out non-critical
|
||||
if (!text.includes('React DevTools') && !text.includes('favicon')) {
|
||||
errors.push(`Console: ${text}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Capture page errors
|
||||
page.on('pageerror', error => {
|
||||
errors.push(`Page error: ${error.message}`);
|
||||
});
|
||||
|
||||
// Capture failed requests
|
||||
page.on('requestfailed', request => {
|
||||
const failedUrl = request.url();
|
||||
if (!failedUrl.includes('favicon') && !failedUrl.includes('hot-update')) {
|
||||
errors.push(`Request failed: ${failedUrl}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
|
||||
// Wait a bit for React to fully render
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check that page rendered something
|
||||
const bodyContent = await page.locator('body').textContent();
|
||||
expect(bodyContent?.length).toBeGreaterThan(100);
|
||||
|
||||
// Report and fail if errors
|
||||
if (errors.length > 0) {
|
||||
console.error(`\n❌ Errors on ${name} page (${url}):`);
|
||||
errors.forEach(e => console.error(` - ${e}`));
|
||||
}
|
||||
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
568
frontend/tests/e2e/utils/crawler.ts
Normal file
568
frontend/tests/e2e/utils/crawler.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Site Crawler Utility
|
||||
* Crawls the site discovering links and capturing errors
|
||||
*/
|
||||
|
||||
import { Page, BrowserContext } from '@playwright/test';
|
||||
|
||||
export interface CrawlError {
|
||||
url: string;
|
||||
type: 'console' | 'network' | 'broken-link' | 'page-error';
|
||||
message: string;
|
||||
details?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface CrawlResult {
|
||||
url: string;
|
||||
status: 'success' | 'error' | 'skipped';
|
||||
title?: string;
|
||||
errors: CrawlError[];
|
||||
linksFound: string[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface CrawlReport {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
totalPages: number;
|
||||
totalErrors: number;
|
||||
results: CrawlResult[];
|
||||
summary: {
|
||||
consoleErrors: number;
|
||||
networkErrors: number;
|
||||
brokenLinks: number;
|
||||
pageErrors: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CrawlerOptions {
|
||||
maxPages?: number;
|
||||
timeout?: number;
|
||||
excludePatterns?: RegExp[];
|
||||
includeExternalLinks?: boolean;
|
||||
waitForNetworkIdle?: boolean;
|
||||
screenshotOnError?: boolean;
|
||||
screenshotDir?: string;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: CrawlerOptions = {
|
||||
maxPages: 0, // 0 = unlimited
|
||||
timeout: 30000,
|
||||
excludePatterns: [
|
||||
/\.(pdf|zip|tar|gz|exe|dmg|pkg)$/i,
|
||||
/^mailto:/i,
|
||||
/^tel:/i,
|
||||
/^javascript:/i,
|
||||
/logout/i,
|
||||
/sign-out/i,
|
||||
],
|
||||
includeExternalLinks: false,
|
||||
waitForNetworkIdle: true,
|
||||
screenshotOnError: false,
|
||||
screenshotDir: 'test-results/crawler-screenshots',
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
export class SiteCrawler {
|
||||
private page: Page;
|
||||
private context: BrowserContext;
|
||||
private options: CrawlerOptions;
|
||||
private visited: Set<string> = new Set();
|
||||
private queue: string[] = [];
|
||||
private results: CrawlResult[] = [];
|
||||
private baseUrl: string = '';
|
||||
private baseDomain: string = '';
|
||||
|
||||
constructor(page: Page, context: BrowserContext, options: Partial<CrawlerOptions> = {}) {
|
||||
this.page = page;
|
||||
this.context = context;
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
private log(message: string, ...args: unknown[]) {
|
||||
if (this.options.verbose) {
|
||||
console.log(`[Crawler] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url, this.baseUrl);
|
||||
// Remove hash and trailing slash for comparison
|
||||
parsed.hash = '';
|
||||
let normalized = parsed.href;
|
||||
if (normalized.endsWith('/') && normalized !== parsed.origin + '/') {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private isInternalUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url, this.baseUrl);
|
||||
// Check if it's on the same domain or a subdomain of lvh.me
|
||||
return parsed.hostname.endsWith('lvh.me') ||
|
||||
parsed.hostname === 'localhost' ||
|
||||
parsed.hostname === '127.0.0.1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldCrawl(url: string): boolean {
|
||||
// Skip if already visited
|
||||
if (this.visited.has(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if matches exclude patterns
|
||||
for (const pattern of this.options.excludePatterns || []) {
|
||||
if (pattern.test(url)) {
|
||||
this.log(`Skipping excluded URL: ${url}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip external links unless explicitly included
|
||||
if (!this.options.includeExternalLinks && !this.isInternalUrl(url)) {
|
||||
this.log(`Skipping external URL: ${url}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async extractLinks(): Promise<string[]> {
|
||||
const links = await this.page.evaluate(() => {
|
||||
const anchors = document.querySelectorAll('a[href]');
|
||||
const hrefs: string[] = [];
|
||||
anchors.forEach(anchor => {
|
||||
const href = anchor.getAttribute('href');
|
||||
if (href) {
|
||||
hrefs.push(href);
|
||||
}
|
||||
});
|
||||
return hrefs;
|
||||
});
|
||||
|
||||
// Normalize and filter links
|
||||
const normalizedLinks: string[] = [];
|
||||
for (const link of links) {
|
||||
try {
|
||||
const normalized = this.normalizeUrl(link);
|
||||
if (this.shouldCrawl(normalized)) {
|
||||
normalizedLinks.push(normalized);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, skip
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(normalizedLinks)];
|
||||
}
|
||||
|
||||
private async crawlPage(url: string): Promise<CrawlResult> {
|
||||
const startTime = Date.now();
|
||||
const errors: CrawlError[] = [];
|
||||
const linksFound: string[] = [];
|
||||
|
||||
this.log(`Crawling: ${url}`);
|
||||
|
||||
// Set up error listeners
|
||||
const consoleHandler = (msg: { type: () => string; text: () => string; location: () => { url: string; lineNumber: number } }) => {
|
||||
const type = msg.type();
|
||||
if (type === 'error' || type === 'warning') {
|
||||
const text = msg.text();
|
||||
// Filter out non-critical warnings
|
||||
if (text.includes('width(-1) and height(-1) of chart')) return; // Recharts initial render warning
|
||||
if (text.includes('WebSocket')) return; // WebSocket connection issues in dev
|
||||
if (text.includes('Cross-Origin-Opener-Policy')) return; // COOP header in dev environment
|
||||
if (text.includes('must use HTTPS') && text.includes('Stripe')) return; // Stripe dev mode warning
|
||||
// Show all backend errors (403, 404, 500) for debugging
|
||||
|
||||
errors.push({
|
||||
url,
|
||||
type: 'console',
|
||||
message: text,
|
||||
details: `${type.toUpperCase()} at ${msg.location().url}:${msg.location().lineNumber}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const pageErrorHandler = (error: Error) => {
|
||||
errors.push({
|
||||
url,
|
||||
type: 'page-error',
|
||||
message: error.message,
|
||||
details: error.stack,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
const requestFailedHandler = (request: { url: () => string; failure: () => { errorText: string } | null }) => {
|
||||
const failedUrl = request.url();
|
||||
// Ignore some common non-critical failures
|
||||
if (failedUrl.includes('favicon.ico') || failedUrl.includes('hot-update')) {
|
||||
return;
|
||||
}
|
||||
// Ignore Stripe external requests (tracking/monitoring that gets cancelled)
|
||||
if (failedUrl.includes('stripe.com') || failedUrl.includes('stripe.network')) {
|
||||
return;
|
||||
}
|
||||
// Show all API failures for debugging
|
||||
errors.push({
|
||||
url,
|
||||
type: 'network',
|
||||
message: `Request failed: ${failedUrl}`,
|
||||
details: request.failure()?.errorText || 'Unknown error',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
const responseHandler = (response: { url: () => string; status: () => number }) => {
|
||||
const status = response.status();
|
||||
const responseUrl = response.url();
|
||||
// Track 4xx and 5xx responses (excluding some common benign ones)
|
||||
if (status >= 400 && !responseUrl.includes('favicon.ico')) {
|
||||
// Show all API errors (403, 404, 500) for debugging
|
||||
|
||||
errors.push({
|
||||
url,
|
||||
type: 'network',
|
||||
message: `HTTP ${status}: ${responseUrl}`,
|
||||
details: `Response status ${status}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.page.on('console', consoleHandler);
|
||||
this.page.on('pageerror', pageErrorHandler);
|
||||
this.page.on('requestfailed', requestFailedHandler);
|
||||
this.page.on('response', responseHandler);
|
||||
|
||||
try {
|
||||
// Navigate to the page
|
||||
const response = await this.page.goto(url, {
|
||||
timeout: this.options.timeout,
|
||||
waitUntil: this.options.waitForNetworkIdle ? 'networkidle' : 'domcontentloaded',
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
errors.push({
|
||||
url,
|
||||
type: 'network',
|
||||
message: 'No response received',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
return {
|
||||
url,
|
||||
status: 'error',
|
||||
errors,
|
||||
linksFound,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
const status = response.status();
|
||||
if (status >= 400) {
|
||||
errors.push({
|
||||
url,
|
||||
type: 'broken-link',
|
||||
message: `HTTP ${status}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Wait a bit for React to render and any async operations
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Get page title
|
||||
const title = await this.page.title();
|
||||
|
||||
// Extract links
|
||||
const links = await this.extractLinks();
|
||||
linksFound.push(...links);
|
||||
|
||||
// Add new links to queue
|
||||
for (const link of links) {
|
||||
if (!this.visited.has(link) && !this.queue.includes(link)) {
|
||||
this.queue.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
// Screenshot on error if enabled
|
||||
if (errors.length > 0 && this.options.screenshotOnError) {
|
||||
const filename = url.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 100);
|
||||
await this.page.screenshot({
|
||||
path: `${this.options.screenshotDir}/${filename}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
status: errors.length > 0 ? 'error' : 'success',
|
||||
title,
|
||||
errors,
|
||||
linksFound,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
url,
|
||||
type: 'page-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
status: 'error',
|
||||
errors,
|
||||
linksFound,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} finally {
|
||||
// Remove listeners
|
||||
this.page.off('console', consoleHandler);
|
||||
this.page.off('pageerror', pageErrorHandler);
|
||||
this.page.off('requestfailed', requestFailedHandler);
|
||||
this.page.off('response', responseHandler);
|
||||
}
|
||||
}
|
||||
|
||||
async crawl(startUrl: string): Promise<CrawlReport> {
|
||||
const startTime = new Date();
|
||||
this.baseUrl = startUrl;
|
||||
this.baseDomain = new URL(startUrl).hostname;
|
||||
this.queue = [this.normalizeUrl(startUrl)];
|
||||
this.visited.clear();
|
||||
this.results = [];
|
||||
|
||||
console.log(`\n🕷️ Starting crawl from: ${startUrl}`);
|
||||
console.log(` Max pages: ${this.options.maxPages || 'unlimited'}`);
|
||||
console.log('');
|
||||
|
||||
const maxPages = this.options.maxPages || 0; // 0 = unlimited
|
||||
while (this.queue.length > 0 && (maxPages === 0 || this.results.length < maxPages)) {
|
||||
const url = this.queue.shift()!;
|
||||
|
||||
if (this.visited.has(url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.visited.add(url);
|
||||
const result = await this.crawlPage(url);
|
||||
this.results.push(result);
|
||||
|
||||
// Progress indicator
|
||||
const errorCount = result.errors.length;
|
||||
const statusIcon = result.status === 'success' ? '✓' : '✗';
|
||||
const errorInfo = errorCount > 0 ? ` (${errorCount} error${errorCount > 1 ? 's' : ''})` : '';
|
||||
const maxDisplay = maxPages === 0 ? '∞' : maxPages;
|
||||
console.log(` ${statusIcon} [${this.results.length}/${maxDisplay}] ${url}${errorInfo}`);
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
|
||||
// Calculate summary
|
||||
const summary = {
|
||||
consoleErrors: 0,
|
||||
networkErrors: 0,
|
||||
brokenLinks: 0,
|
||||
pageErrors: 0,
|
||||
};
|
||||
|
||||
for (const result of this.results) {
|
||||
for (const error of result.errors) {
|
||||
switch (error.type) {
|
||||
case 'console':
|
||||
summary.consoleErrors++;
|
||||
break;
|
||||
case 'network':
|
||||
summary.networkErrors++;
|
||||
break;
|
||||
case 'broken-link':
|
||||
summary.brokenLinks++;
|
||||
break;
|
||||
case 'page-error':
|
||||
summary.pageErrors++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startTime,
|
||||
endTime,
|
||||
totalPages: this.results.length,
|
||||
totalErrors: summary.consoleErrors + summary.networkErrors + summary.brokenLinks + summary.pageErrors,
|
||||
results: this.results,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReport(report: CrawlReport): string {
|
||||
const duration = (report.endTime.getTime() - report.startTime.getTime()) / 1000;
|
||||
|
||||
let output = '\n';
|
||||
output += '═'.repeat(60) + '\n';
|
||||
output += ' CRAWL REPORT\n';
|
||||
output += '═'.repeat(60) + '\n\n';
|
||||
|
||||
output += `📊 Summary\n`;
|
||||
output += ` Pages crawled: ${report.totalPages}\n`;
|
||||
output += ` Total errors: ${report.totalErrors}\n`;
|
||||
output += ` Duration: ${duration.toFixed(1)}s\n\n`;
|
||||
|
||||
output += `📋 Error Breakdown\n`;
|
||||
output += ` Console errors: ${report.summary.consoleErrors}\n`;
|
||||
output += ` Network errors: ${report.summary.networkErrors}\n`;
|
||||
output += ` Broken links: ${report.summary.brokenLinks}\n`;
|
||||
output += ` Page errors: ${report.summary.pageErrors}\n\n`;
|
||||
|
||||
// List pages with errors
|
||||
const pagesWithErrors = report.results.filter(r => r.errors.length > 0);
|
||||
if (pagesWithErrors.length > 0) {
|
||||
output += '─'.repeat(60) + '\n';
|
||||
output += ' ERROR DETAILS\n';
|
||||
output += '─'.repeat(60) + '\n\n';
|
||||
|
||||
for (const result of pagesWithErrors) {
|
||||
output += `🔗 ${result.url}\n`;
|
||||
output += ` Title: ${result.title || 'N/A'}\n`;
|
||||
|
||||
for (const error of result.errors) {
|
||||
const icon = error.type === 'console' ? '⚠️' :
|
||||
error.type === 'network' ? '🌐' :
|
||||
error.type === 'broken-link' ? '🔴' : '💥';
|
||||
output += ` ${icon} [${error.type.toUpperCase()}] ${error.message}\n`;
|
||||
if (error.details) {
|
||||
output += ` Details: ${error.details.substring(0, 200)}\n`;
|
||||
}
|
||||
}
|
||||
output += '\n';
|
||||
}
|
||||
} else {
|
||||
output += '✅ No errors found!\n\n';
|
||||
}
|
||||
|
||||
output += '═'.repeat(60) + '\n';
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Authentication helpers for different user types
|
||||
export interface UserCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
loginUrl: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const TEST_USERS: Record<string, UserCredentials> = {
|
||||
platformSuperuser: {
|
||||
username: 'poduck@gmail.com',
|
||||
password: 'starry12',
|
||||
loginUrl: 'http://platform.lvh.me:5173/platform/login',
|
||||
description: 'Platform Superuser',
|
||||
},
|
||||
businessOwner: {
|
||||
username: 'owner@demo.com',
|
||||
password: 'password123',
|
||||
loginUrl: 'http://demo.lvh.me:5173/login',
|
||||
description: 'Business Owner',
|
||||
},
|
||||
businessManager: {
|
||||
username: 'manager@demo.com',
|
||||
password: 'password123',
|
||||
loginUrl: 'http://demo.lvh.me:5173/login',
|
||||
description: 'Business Manager',
|
||||
},
|
||||
businessStaff: {
|
||||
username: 'staff@demo.com',
|
||||
password: 'password123',
|
||||
loginUrl: 'http://demo.lvh.me:5173/login',
|
||||
description: 'Staff Member',
|
||||
},
|
||||
customer: {
|
||||
username: 'customer@demo.com',
|
||||
password: 'password123',
|
||||
loginUrl: 'http://demo.lvh.me:5173/login',
|
||||
description: 'Customer',
|
||||
},
|
||||
};
|
||||
|
||||
export async function loginAsUser(page: Page, user: UserCredentials): Promise<boolean> {
|
||||
console.log(`\n🔐 Logging in as ${user.description}...`);
|
||||
console.log(` Login URL: ${user.loginUrl}`);
|
||||
|
||||
try {
|
||||
await page.goto(user.loginUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check if already logged in (dashboard visible)
|
||||
const isDashboard = page.url().includes('/dashboard') ||
|
||||
await page.getByRole('heading', { name: /dashboard/i }).isVisible().catch(() => false);
|
||||
|
||||
if (isDashboard) {
|
||||
console.log(` ✓ Already logged in. Current URL: ${page.url()}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try quick login buttons first (dev mode)
|
||||
const quickLoginButton = page.getByRole('button', { name: new RegExp(user.description, 'i') });
|
||||
const hasQuickLogin = await quickLoginButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasQuickLogin) {
|
||||
console.log(` Using quick login button for ${user.description}...`);
|
||||
await quickLoginButton.click();
|
||||
} else {
|
||||
// Fall back to form login
|
||||
let emailInput = page.locator('#email');
|
||||
let passwordInput = page.locator('#password');
|
||||
|
||||
const formFound = await emailInput.waitFor({ timeout: 10000 }).then(() => true).catch(() => false);
|
||||
|
||||
if (!formFound) {
|
||||
emailInput = page.getByPlaceholder(/enter your email/i);
|
||||
passwordInput = page.getByPlaceholder(/password/i);
|
||||
await emailInput.waitFor({ timeout: 5000 });
|
||||
}
|
||||
|
||||
await emailInput.fill(user.username);
|
||||
await passwordInput.fill(user.password);
|
||||
await page.getByRole('button', { name: /^sign in$/i }).click();
|
||||
}
|
||||
|
||||
// Wait for navigation after login
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify we're logged in (not on login page anymore)
|
||||
const currentUrl = page.url();
|
||||
const isLoggedIn = !currentUrl.includes('/login') &&
|
||||
!currentUrl.endsWith(':5173/') &&
|
||||
!currentUrl.endsWith(':5173');
|
||||
|
||||
if (isLoggedIn) {
|
||||
console.log(` ✓ Logged in successfully. Current URL: ${currentUrl}`);
|
||||
} else {
|
||||
console.log(` ✗ Login may have failed. Current URL: ${currentUrl}`);
|
||||
}
|
||||
|
||||
return isLoggedIn;
|
||||
} catch (error) {
|
||||
console.error(` ✗ Login failed:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user