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:
poduck
2025-12-16 11:56:01 -05:00
parent c333010620
commit cfb626b595
62 changed files with 4914 additions and 2413 deletions

View 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);
});
}
});

View 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;
}
}