Update E2E tests and simplify performance tests
CI Pipeline / e2e (chromium) (pull_request) Successful in 6m13s
CI Pipeline / e2e (firefox) (pull_request) Successful in 7m3s
CI Pipeline / e2e (webkit) (pull_request) Successful in 5m52s
CI Pipeline / visual-regression (pull_request) Successful in 7m48s
CI Pipeline / performance (pull_request) Successful in 7m59s
CI Pipeline / lint (pull_request) Successful in 6m16s
CI Pipeline / build (pull_request) Successful in 5m30s
CI Pipeline / test (pull_request) Successful in 6m26s
@@ -307,13 +307,7 @@ jobs:
|
|||||||
- name: Build application
|
- name: Build application
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Comprehensive Performance Testing
|
# 1) Sanity check that the build exists
|
||||||
run: |
|
|
||||||
echo "🧪 Running comprehensive performance testing..."
|
|
||||||
npm run test:performance:ci
|
|
||||||
echo "✅ Performance testing complete"
|
|
||||||
|
|
||||||
# 1) Sanity check that the build exists
|
|
||||||
- name: Verify Next build output
|
- name: Verify Next build output
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
@@ -72,16 +72,27 @@ This project includes comprehensive performance optimizations for sub-2-second l
|
|||||||
|
|
||||||
### Performance Monitoring
|
### Performance Monitoring
|
||||||
|
|
||||||
```bash
|
Performance testing is handled by:
|
||||||
# Individual monitoring tools
|
|
||||||
npm run bundle:analyze # Analyze bundle sizes and budgets
|
|
||||||
npm run performance:monitor # Performance metrics and Lighthouse CI
|
|
||||||
npm run web-vitals:track # Core Web Vitals tracking
|
|
||||||
|
|
||||||
# Comprehensive testing
|
- **Lighthouse CI** (`.lighthouserc.json`): Comprehensive performance testing in CI
|
||||||
npm run test:performance # All performance tests
|
|
||||||
npm run monitor:all # All monitoring tools
|
```bash
|
||||||
```
|
npm run lhci # Run Lighthouse CI
|
||||||
|
npm run lhci:mobile # Mobile preset
|
||||||
|
npm run lhci:desktop # Desktop preset
|
||||||
|
npm run performance:budget # With performance budgets
|
||||||
|
```
|
||||||
|
|
||||||
|
- **E2E Performance Tests** (`tests/e2e/performance.spec.ts`): Essential performance checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run e2e:performance # Run E2E performance tests
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Bundle Analysis**: Analyze bundle sizes
|
||||||
|
```bash
|
||||||
|
npm run bundle:analyze # Analyze bundle sizes
|
||||||
|
```
|
||||||
|
|
||||||
### Performance Targets
|
### Performance Targets
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ tests/
|
|||||||
home.test.jsx
|
home.test.jsx
|
||||||
blog.test.jsx
|
blog.test.jsx
|
||||||
e2e/ # True end‑to‑end flows + visual regression (Playwright)
|
e2e/ # True end‑to‑end flows + visual regression (Playwright)
|
||||||
homepage.spec.ts
|
critical-journeys.spec.ts # Main user journeys (homepage, navigation, interactions)
|
||||||
user-journeys.spec.ts
|
visual-regression.spec.ts # Critical page screenshots only (5 tests)
|
||||||
visual-regression.spec.ts
|
edge-cases.spec.ts # Critical error scenarios (4 tests)
|
||||||
performance.spec.ts
|
performance.spec.ts # Essential performance checks (2 tests)
|
||||||
utils/ # Shared test utilities
|
utils/ # Shared test utilities
|
||||||
componentTestSuite.tsx
|
componentTestSuite.tsx
|
||||||
msw/ # MSW server setup for mocking
|
msw/ # MSW server setup for mocking
|
||||||
@@ -38,6 +38,18 @@ tests/
|
|||||||
|
|
||||||
**Component tests** (`tests/components/`) use the standard `componentTestSuite` utility to ensure consistent baseline coverage for all UI components. **Page tests** (`tests/pages/`) cover page-level rendering and flows. **E2E tests** (`tests/e2e/`) focus on critical user journeys, visual regression, and performance. **Accessibility E2E** (`tests/accessibility/e2e/`) provides high-level WCAG compliance checks.
|
**Component tests** (`tests/components/`) use the standard `componentTestSuite` utility to ensure consistent baseline coverage for all UI components. **Page tests** (`tests/pages/`) cover page-level rendering and flows. **E2E tests** (`tests/e2e/`) focus on critical user journeys, visual regression, and performance. **Accessibility E2E** (`tests/accessibility/e2e/`) provides high-level WCAG compliance checks.
|
||||||
|
|
||||||
|
### E2E Testing Philosophy
|
||||||
|
|
||||||
|
E2E tests follow a **sparse, critical-path approach** optimized for open source projects:
|
||||||
|
|
||||||
|
- **Focus on user value**: Test critical user journeys that span multiple pages or systems, not individual component interactions
|
||||||
|
- **Maintainability over coverage**: Keep tests maintainable and contributor-friendly rather than comprehensive
|
||||||
|
- **Visual regression is minimal**: Only capture screenshots of major pages (homepage, blog listing/post, 404), not every component or viewport
|
||||||
|
- **Performance monitoring is essential**: Track homepage load and Core Web Vitals, but detailed performance analysis is handled by Lighthouse CI
|
||||||
|
- **Edge cases are critical only**: Test scenarios that would break user experience (slow network, offline mode, JS errors, missing images)
|
||||||
|
|
||||||
|
This approach reduces test maintenance burden while ensuring critical functionality remains stable.
|
||||||
|
|
||||||
### Standard Component Test Suite
|
### Standard Component Test Suite
|
||||||
|
|
||||||
Use the shared suite in `tests/utils/componentTestSuite.tsx` to get a consistent baseline:
|
Use the shared suite in `tests/utils/componentTestSuite.tsx` to get a consistent baseline:
|
||||||
@@ -182,15 +194,51 @@ describe("Input – behaviour specifics", () => {
|
|||||||
|
|
||||||
### E2E and Visual Regression
|
### E2E and Visual Regression
|
||||||
|
|
||||||
- Use **Playwright** for:
|
E2E tests are organized into focused files:
|
||||||
- Critical user journeys (e.g., create rule, navigate blog, key flows).
|
|
||||||
- Responsive behaviour and cross‑browser checks.
|
|
||||||
- Visual regression (`tests/e2e/visual-regression.spec.ts`).
|
|
||||||
|
|
||||||
```bash
|
- **`critical-journeys.spec.ts`**: Main user journeys (homepage loads, navigation, key interactions)
|
||||||
npm run test:e2e
|
- **`visual-regression.spec.ts`**: Critical page screenshots only (homepage full/viewport, blog listing/post, 404)
|
||||||
npm run visual:test
|
- **`edge-cases.spec.ts`**: Critical error scenarios (slow network, offline mode, JS errors, missing images)
|
||||||
```
|
- **`performance.spec.ts`**: Essential performance checks (homepage load, Core Web Vitals)
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all E2E tests
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# Run visual regression tests only
|
||||||
|
npm run visual:test
|
||||||
|
|
||||||
|
# Update visual regression snapshots (after UI changes)
|
||||||
|
npm run visual:update
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
npx playwright test tests/e2e/critical-journeys.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to add E2E tests:**
|
||||||
|
|
||||||
|
- **Add E2E tests** when:
|
||||||
|
- A new critical user journey is introduced (e.g., new multi-step flow)
|
||||||
|
- A major page is added that needs visual regression coverage
|
||||||
|
- A critical error scenario needs to be tested (e.g., payment failure, form submission errors)
|
||||||
|
|
||||||
|
- **Don't add E2E tests** for:
|
||||||
|
- Component-level interactions (use component tests instead)
|
||||||
|
- Single-page functionality (use page tests instead)
|
||||||
|
- Minor UI changes (visual regression will catch major regressions)
|
||||||
|
- Edge cases that don't impact core user experience
|
||||||
|
|
||||||
|
**Visual regression snapshots:**
|
||||||
|
|
||||||
|
Visual regression tests capture screenshots of critical pages. When UI changes are intentional, update snapshots:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run visual:update
|
||||||
|
```
|
||||||
|
|
||||||
|
This updates snapshots for all 5 critical page tests. Review the changes carefully before committing.
|
||||||
|
|
||||||
### Storybook
|
### Storybook
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,6 @@
|
|||||||
"lhci:mobile": "lhci autorun --settings.preset=mobile",
|
"lhci:mobile": "lhci autorun --settings.preset=mobile",
|
||||||
"lhci:desktop": "lhci autorun --settings.preset=desktop",
|
"lhci:desktop": "lhci autorun --settings.preset=desktop",
|
||||||
"performance:budget": "lhci autorun --budgetPath=performance-budgets.json",
|
"performance:budget": "lhci autorun --budgetPath=performance-budgets.json",
|
||||||
"performance:monitor": "node scripts/performance-monitor.js",
|
|
||||||
"test:lhci": "node scripts/test-lhci.js",
|
|
||||||
"preview": "next build && next start -p 3000",
|
"preview": "next build && next start -p 3000",
|
||||||
"e2e:serve": "start-server-and-test preview http://localhost:3000 e2e",
|
"e2e:serve": "start-server-and-test preview http://localhost:3000 e2e",
|
||||||
"seed-snapshots": "./scripts/seed-snapshots.sh",
|
"seed-snapshots": "./scripts/seed-snapshots.sh",
|
||||||
@@ -41,11 +39,7 @@
|
|||||||
"analyze": "npm run analyze:browser && npm run analyze:server",
|
"analyze": "npm run analyze:browser && npm run analyze:server",
|
||||||
"analyze:server": "ANALYZE=true npm run build",
|
"analyze:server": "ANALYZE=true npm run build",
|
||||||
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
|
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
|
||||||
"bundle:analyze": "node scripts/bundle-analyzer.js",
|
"bundle:analyze": "node scripts/bundle-analyzer.js"
|
||||||
"web-vitals:track": "node scripts/web-vitals-tracker.js",
|
|
||||||
"monitor:all": "npm run bundle:analyze && npm run performance:monitor && npm run web-vitals:track",
|
|
||||||
"test:performance": "node scripts/test-performance.js",
|
|
||||||
"test:performance:ci": "npm run test:performance"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
|
|||||||
@@ -33,14 +33,15 @@ export default defineConfig({
|
|||||||
headless: true,
|
headless: true,
|
||||||
},
|
},
|
||||||
// Only start webServer in non-CI environments (CI starts its own server)
|
// Only start webServer in non-CI environments (CI starts its own server)
|
||||||
|
// Use production server for E2E tests to match CI and ensure reliability
|
||||||
...(process.env.CI
|
...(process.env.CI
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run dev -- --port 3010",
|
command: "npm run build && npx next start -p 3010",
|
||||||
url: "http://localhost:3010",
|
url: "http://localhost:3010",
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 180_000, // Increased timeout to account for build time
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// Browser-specific snapshot path template (includes projectName for cross-browser support)
|
// Browser-specific snapshot path template (includes projectName for cross-browser support)
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performance Monitoring Script
|
|
||||||
* Monitors Core Web Vitals and performance metrics
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
|
|
||||||
const MONITORING_DIR = path.join(__dirname, "..", ".next", "monitoring");
|
|
||||||
|
|
||||||
class PerformanceMonitor {
|
|
||||||
constructor() {
|
|
||||||
this.metrics = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
coreWebVitals: {},
|
|
||||||
bundleMetrics: {},
|
|
||||||
recommendations: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run comprehensive performance monitoring
|
|
||||||
*/
|
|
||||||
async monitorPerformance() {
|
|
||||||
console.log("📊 Starting performance monitoring...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure monitoring directory exists
|
|
||||||
if (!fs.existsSync(MONITORING_DIR)) {
|
|
||||||
fs.mkdirSync(MONITORING_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run Lighthouse CI for Core Web Vitals
|
|
||||||
await this.runLighthouseCI();
|
|
||||||
|
|
||||||
// Analyze bundle performance
|
|
||||||
await this.analyzeBundlePerformance();
|
|
||||||
|
|
||||||
// Check performance budgets
|
|
||||||
this.checkPerformanceBudgets();
|
|
||||||
|
|
||||||
// Generate performance report
|
|
||||||
this.generatePerformanceReport();
|
|
||||||
|
|
||||||
console.log("✅ Performance monitoring complete!");
|
|
||||||
console.log(`📁 Results saved to: ${MONITORING_DIR}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Performance monitoring failed:", error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Lighthouse CI for Core Web Vitals
|
|
||||||
*/
|
|
||||||
async runLighthouseCI() {
|
|
||||||
console.log("🔍 Running Lighthouse CI...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if server is running
|
|
||||||
const { execSync } = require("child_process");
|
|
||||||
try {
|
|
||||||
execSync("curl -s http://localhost:3000 > /dev/null", {
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
console.warn(
|
|
||||||
"⚠️ Development server not running, skipping Lighthouse CI...",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run Lighthouse CI with performance focus
|
|
||||||
execSync("npx lhci autorun --collect.url=http://localhost:3000", {
|
|
||||||
stdio: "inherit",
|
|
||||||
cwd: path.join(__dirname, ".."),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse Lighthouse results
|
|
||||||
await this.parseLighthouseResults();
|
|
||||||
} catch {
|
|
||||||
console.warn("⚠️ Lighthouse CI failed, continuing with other metrics...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse Lighthouse CI results
|
|
||||||
*/
|
|
||||||
async parseLighthouseResults() {
|
|
||||||
const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
|
|
||||||
|
|
||||||
if (fs.existsSync(lhciResultsPath)) {
|
|
||||||
const files = fs.readdirSync(lhciResultsPath);
|
|
||||||
const resultFile = files.find((f) => f.endsWith(".json"));
|
|
||||||
|
|
||||||
if (resultFile) {
|
|
||||||
const results = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (results.lhr && results.lhr.audits) {
|
|
||||||
this.metrics.coreWebVitals = {
|
|
||||||
lcp: this.getAuditScore(
|
|
||||||
results.lhr.audits,
|
|
||||||
"largest-contentful-paint",
|
|
||||||
),
|
|
||||||
fid: this.getAuditScore(results.lhr.audits, "max-potential-fid"),
|
|
||||||
cls: this.getAuditScore(
|
|
||||||
results.lhr.audits,
|
|
||||||
"cumulative-layout-shift",
|
|
||||||
),
|
|
||||||
fcp: this.getAuditScore(
|
|
||||||
results.lhr.audits,
|
|
||||||
"first-contentful-paint",
|
|
||||||
),
|
|
||||||
tti: this.getAuditScore(results.lhr.audits, "interactive"),
|
|
||||||
performance: results.lhr.categories.performance?.score * 100 || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get audit score from Lighthouse results
|
|
||||||
*/
|
|
||||||
getAuditScore(audits, auditId) {
|
|
||||||
const audit = audits[auditId];
|
|
||||||
if (!audit) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
score: audit.score * 100,
|
|
||||||
value: audit.numericValue,
|
|
||||||
displayValue: audit.displayValue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze bundle performance
|
|
||||||
*/
|
|
||||||
async analyzeBundlePerformance() {
|
|
||||||
console.log("📦 Analyzing bundle performance...");
|
|
||||||
|
|
||||||
const bundleStatsPath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
".next",
|
|
||||||
"static",
|
|
||||||
"chunks",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fs.existsSync(bundleStatsPath)) {
|
|
||||||
const files = fs.readdirSync(bundleStatsPath);
|
|
||||||
let totalSize = 0;
|
|
||||||
let jsFiles = 0;
|
|
||||||
|
|
||||||
files.forEach((file) => {
|
|
||||||
if (file.endsWith(".js")) {
|
|
||||||
const filePath = path.join(bundleStatsPath, file);
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
totalSize += stats.size;
|
|
||||||
jsFiles++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.metrics.bundleMetrics = {
|
|
||||||
totalSizeKB: Math.round(totalSize / 1024),
|
|
||||||
totalSizeMB: Math.round((totalSize / (1024 * 1024)) * 100) / 100,
|
|
||||||
fileCount: jsFiles,
|
|
||||||
averageSizeKB: Math.round(totalSize / jsFiles / 1024),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check performance budgets
|
|
||||||
*/
|
|
||||||
checkPerformanceBudgets() {
|
|
||||||
const budgets = PERFORMANCE_BUDGETS.budgets;
|
|
||||||
const violations = [];
|
|
||||||
|
|
||||||
// Check Core Web Vitals
|
|
||||||
if (this.metrics.coreWebVitals.lcp) {
|
|
||||||
const lcpValue = this.metrics.coreWebVitals.lcp.value;
|
|
||||||
const lcpBudget = budgets.find((b) => b.name === "lcp")?.maxValue;
|
|
||||||
|
|
||||||
if (lcpBudget && lcpValue > lcpBudget) {
|
|
||||||
violations.push({
|
|
||||||
metric: "LCP",
|
|
||||||
current: lcpValue,
|
|
||||||
budget: lcpBudget,
|
|
||||||
severity: lcpValue > lcpBudget * 1.5 ? "high" : "medium",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check bundle size
|
|
||||||
if (this.metrics.bundleMetrics.totalSizeKB > 2000) {
|
|
||||||
violations.push({
|
|
||||||
metric: "Bundle Size",
|
|
||||||
current: this.metrics.bundleMetrics.totalSizeKB,
|
|
||||||
budget: 2000,
|
|
||||||
severity: "medium",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.metrics.budgetViolations = violations;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate performance report
|
|
||||||
*/
|
|
||||||
generatePerformanceReport() {
|
|
||||||
const reportPath = path.join(MONITORING_DIR, "performance-report.json");
|
|
||||||
fs.writeFileSync(reportPath, JSON.stringify(this.metrics, null, 2));
|
|
||||||
|
|
||||||
// Generate markdown report
|
|
||||||
this.generateMarkdownReport();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate markdown performance report
|
|
||||||
*/
|
|
||||||
generateMarkdownReport() {
|
|
||||||
const reportPath = path.join(MONITORING_DIR, "performance-report.md");
|
|
||||||
|
|
||||||
let report = `# Performance Monitoring Report\n\n`;
|
|
||||||
report += `**Generated:** ${this.metrics.timestamp}\n\n`;
|
|
||||||
|
|
||||||
// Core Web Vitals
|
|
||||||
if (Object.keys(this.metrics.coreWebVitals).length > 0) {
|
|
||||||
report += `## Core Web Vitals\n\n`;
|
|
||||||
report += `| Metric | Score | Value | Status |\n`;
|
|
||||||
report += `|--------|-------|-------|--------|\n`;
|
|
||||||
|
|
||||||
Object.entries(this.metrics.coreWebVitals).forEach(([metric, data]) => {
|
|
||||||
if (data && typeof data === "object" && data.score !== undefined) {
|
|
||||||
const status = this.getMetricStatus(metric, data.score);
|
|
||||||
report += `| ${metric.toUpperCase()} | ${data.score} | ${
|
|
||||||
data.displayValue || "N/A"
|
|
||||||
} | ${status} |\n`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bundle Metrics
|
|
||||||
if (Object.keys(this.metrics.bundleMetrics).length > 0) {
|
|
||||||
report += `\n## Bundle Metrics\n\n`;
|
|
||||||
report += `- **Total Size:** ${this.metrics.bundleMetrics.totalSizeMB}MB (${this.metrics.bundleMetrics.totalSizeKB}KB)\n`;
|
|
||||||
report += `- **File Count:** ${this.metrics.bundleMetrics.fileCount}\n`;
|
|
||||||
report += `- **Average Size:** ${this.metrics.bundleMetrics.averageSizeKB}KB per file\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Budget Violations
|
|
||||||
if (
|
|
||||||
this.metrics.budgetViolations &&
|
|
||||||
this.metrics.budgetViolations.length > 0
|
|
||||||
) {
|
|
||||||
report += `\n## Budget Violations\n\n`;
|
|
||||||
this.metrics.budgetViolations.forEach((violation) => {
|
|
||||||
report += `- **${violation.metric}**: ${violation.current} (exceeds ${
|
|
||||||
violation.budget
|
|
||||||
}) - ${violation.severity.toUpperCase()}\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recommendations
|
|
||||||
report += `\n## Recommendations\n\n`;
|
|
||||||
report += `- Monitor Core Web Vitals regularly\n`;
|
|
||||||
report += `- Implement code splitting for large bundles\n`;
|
|
||||||
report += `- Use dynamic imports for non-critical components\n`;
|
|
||||||
report += `- Optimize images and fonts\n`;
|
|
||||||
report += `- Enable compression and caching\n`;
|
|
||||||
|
|
||||||
fs.writeFileSync(reportPath, report);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get status emoji for metric score
|
|
||||||
*/
|
|
||||||
getMetricStatus(metric, score) {
|
|
||||||
if (score >= 90) return "✅ Good";
|
|
||||||
if (score >= 50) return "⚠️ Needs Improvement";
|
|
||||||
return "❌ Poor";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run monitoring if called directly
|
|
||||||
if (require.main === module) {
|
|
||||||
const monitor = new PerformanceMonitor();
|
|
||||||
monitor.monitorPerformance().catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = PerformanceMonitor;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple test script to verify LHCI configuration
|
|
||||||
* This script validates the configuration without running actual tests
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
console.log("🔍 Testing LHCI Configuration...\n");
|
|
||||||
|
|
||||||
// Check if .lighthouserc.json exists
|
|
||||||
const configPath = path.join(process.cwd(), ".lighthouserc.json");
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
console.log("✅ .lighthouserc.json found");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
||||||
console.log("✅ Configuration is valid JSON");
|
|
||||||
|
|
||||||
if (config.ci && config.ci.collect && config.ci.assert) {
|
|
||||||
console.log("✅ Configuration has required sections (collect, assert)");
|
|
||||||
console.log(`✅ Testing ${config.ci.collect.numberOfRuns} runs`);
|
|
||||||
console.log(`✅ URL: ${config.ci.collect.url[0]}`);
|
|
||||||
} else {
|
|
||||||
console.log("❌ Configuration missing required sections");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log("❌ Configuration is not valid JSON:", error.message);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("❌ .lighthouserc.json not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if @lhci/cli is installed
|
|
||||||
try {
|
|
||||||
const { execSync } = require("child_process");
|
|
||||||
execSync("npx lhci --version", { stdio: "pipe" });
|
|
||||||
console.log("✅ @lhci/cli package is installed and working");
|
|
||||||
} catch (error) {
|
|
||||||
console.log("❌ @lhci/cli package is not working:", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check package.json scripts
|
|
||||||
const packagePath = path.join(process.cwd(), "package.json");
|
|
||||||
if (fs.existsSync(packagePath)) {
|
|
||||||
try {
|
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
||||||
if (packageJson.scripts && packageJson.scripts.lhci) {
|
|
||||||
console.log("✅ LHCI script found in package.json");
|
|
||||||
} else {
|
|
||||||
console.log("❌ LHCI script not found in package.json");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log("❌ Error reading package.json:", error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n🎉 LHCI Configuration Test Complete!");
|
|
||||||
console.log(
|
|
||||||
"Note: Actual LHCI tests may fail locally due to Node.js architecture issues on macOS.",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"The CI environment should work correctly with the provided configuration.",
|
|
||||||
);
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comprehensive Performance Testing Script
|
|
||||||
* Integrates bundle analysis, performance monitoring, and Web Vitals tracking
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { execSync } = require("child_process");
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const TEST_RESULTS_DIR = path.join(__dirname, "..", ".next", "test-results");
|
|
||||||
|
|
||||||
class PerformanceTester {
|
|
||||||
constructor() {
|
|
||||||
this.results = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
bundleAnalysis: {},
|
|
||||||
performanceMonitoring: {},
|
|
||||||
webVitals: {},
|
|
||||||
lighthouse: {},
|
|
||||||
summary: {
|
|
||||||
passed: 0,
|
|
||||||
failed: 0,
|
|
||||||
warnings: 0,
|
|
||||||
total: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run comprehensive performance testing
|
|
||||||
*/
|
|
||||||
async runTests() {
|
|
||||||
console.log("🧪 Starting comprehensive performance testing...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure test results directory exists
|
|
||||||
if (!fs.existsSync(TEST_RESULTS_DIR)) {
|
|
||||||
fs.mkdirSync(TEST_RESULTS_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Bundle Analysis
|
|
||||||
console.log("📊 Running bundle analysis...");
|
|
||||||
await this.runBundleAnalysis();
|
|
||||||
|
|
||||||
// 2. Performance Monitoring
|
|
||||||
console.log("📈 Running performance monitoring...");
|
|
||||||
await this.runPerformanceMonitoring();
|
|
||||||
|
|
||||||
// 3. Web Vitals Tracking
|
|
||||||
console.log("📊 Setting up Web Vitals tracking...");
|
|
||||||
await this.runWebVitalsTracking();
|
|
||||||
|
|
||||||
// 4. Lighthouse CI (if server is available)
|
|
||||||
console.log("🔍 Running Lighthouse CI...");
|
|
||||||
await this.runLighthouseCI();
|
|
||||||
|
|
||||||
// 5. Generate comprehensive report
|
|
||||||
this.generateComprehensiveReport();
|
|
||||||
|
|
||||||
console.log("✅ Performance testing complete!");
|
|
||||||
console.log(`📁 Results saved to: ${TEST_RESULTS_DIR}`);
|
|
||||||
|
|
||||||
// Return exit code based on results
|
|
||||||
const hasFailures = this.results.summary.failed > 0;
|
|
||||||
if (hasFailures) {
|
|
||||||
console.log("❌ Performance tests failed");
|
|
||||||
process.exit(1);
|
|
||||||
} else {
|
|
||||||
console.log("✅ All performance tests passed");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Performance testing failed:", error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run bundle analysis
|
|
||||||
*/
|
|
||||||
async runBundleAnalysis() {
|
|
||||||
try {
|
|
||||||
execSync("npm run bundle:analyze", { stdio: "inherit" });
|
|
||||||
|
|
||||||
// Parse bundle analysis results
|
|
||||||
const bundleReportPath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
".next",
|
|
||||||
"analyze",
|
|
||||||
"bundle-analysis.json",
|
|
||||||
);
|
|
||||||
if (fs.existsSync(bundleReportPath)) {
|
|
||||||
const bundleData = JSON.parse(
|
|
||||||
fs.readFileSync(bundleReportPath, "utf8"),
|
|
||||||
);
|
|
||||||
this.results.bundleAnalysis = bundleData;
|
|
||||||
|
|
||||||
// Check for budget violations
|
|
||||||
if (
|
|
||||||
bundleData.budgetViolations &&
|
|
||||||
bundleData.budgetViolations.length > 0
|
|
||||||
) {
|
|
||||||
this.results.summary.failed += bundleData.budgetViolations.length;
|
|
||||||
console.log(
|
|
||||||
`⚠️ Found ${bundleData.budgetViolations.length} budget violations`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.results.summary.passed += 1;
|
|
||||||
console.log("✅ Bundle analysis passed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.results.summary.total += 1;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Bundle analysis failed:", error.message);
|
|
||||||
this.results.summary.failed += 1;
|
|
||||||
this.results.summary.total += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run performance monitoring
|
|
||||||
*/
|
|
||||||
async runPerformanceMonitoring() {
|
|
||||||
try {
|
|
||||||
execSync("npm run performance:monitor", { stdio: "inherit" });
|
|
||||||
|
|
||||||
// Parse performance monitoring results
|
|
||||||
const perfReportPath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
".next",
|
|
||||||
"monitoring",
|
|
||||||
"performance-report.json",
|
|
||||||
);
|
|
||||||
if (fs.existsSync(perfReportPath)) {
|
|
||||||
const perfData = JSON.parse(fs.readFileSync(perfReportPath, "utf8"));
|
|
||||||
this.results.performanceMonitoring = perfData;
|
|
||||||
|
|
||||||
// Check for budget violations
|
|
||||||
if (perfData.budgetViolations && perfData.budgetViolations.length > 0) {
|
|
||||||
this.results.summary.failed += perfData.budgetViolations.length;
|
|
||||||
console.log(
|
|
||||||
`⚠️ Found ${perfData.budgetViolations.length} performance violations`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.results.summary.passed += 1;
|
|
||||||
console.log("✅ Performance monitoring passed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.results.summary.total += 1;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Performance monitoring failed:", error.message);
|
|
||||||
this.results.summary.failed += 1;
|
|
||||||
this.results.summary.total += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Web Vitals tracking
|
|
||||||
*/
|
|
||||||
async runWebVitalsTracking() {
|
|
||||||
try {
|
|
||||||
execSync("npm run web-vitals:track", { stdio: "inherit" });
|
|
||||||
|
|
||||||
// Parse Web Vitals results
|
|
||||||
const vitalsReportPath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
".next",
|
|
||||||
"web-vitals",
|
|
||||||
"report.json",
|
|
||||||
);
|
|
||||||
if (fs.existsSync(vitalsReportPath)) {
|
|
||||||
const vitalsData = JSON.parse(
|
|
||||||
fs.readFileSync(vitalsReportPath, "utf8"),
|
|
||||||
);
|
|
||||||
this.results.webVitals = vitalsData;
|
|
||||||
console.log("✅ Web Vitals tracking setup complete");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.results.summary.passed += 1;
|
|
||||||
this.results.summary.total += 1;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Web Vitals tracking failed:", error.message);
|
|
||||||
this.results.summary.failed += 1;
|
|
||||||
this.results.summary.total += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Lighthouse CI
|
|
||||||
*/
|
|
||||||
async runLighthouseCI() {
|
|
||||||
try {
|
|
||||||
// Check if server is running
|
|
||||||
try {
|
|
||||||
execSync("curl -s http://localhost:3000 > /dev/null", {
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
console.warn(
|
|
||||||
"⚠️ Development server not running, skipping Lighthouse CI...",
|
|
||||||
);
|
|
||||||
this.results.summary.warnings += 1;
|
|
||||||
this.results.summary.total += 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
execSync("npm run lhci", { stdio: "inherit" });
|
|
||||||
|
|
||||||
// Parse Lighthouse results
|
|
||||||
const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
|
|
||||||
if (fs.existsSync(lhciResultsPath)) {
|
|
||||||
const files = fs.readdirSync(lhciResultsPath);
|
|
||||||
const resultFile = files.find((f) => f.endsWith(".json"));
|
|
||||||
|
|
||||||
if (resultFile) {
|
|
||||||
const lhciData = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8"),
|
|
||||||
);
|
|
||||||
this.results.lighthouse = lhciData;
|
|
||||||
console.log("✅ Lighthouse CI completed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.results.summary.passed += 1;
|
|
||||||
this.results.summary.total += 1;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("⚠️ Lighthouse CI failed:", error.message);
|
|
||||||
this.results.summary.warnings += 1;
|
|
||||||
this.results.summary.total += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate comprehensive test report
|
|
||||||
*/
|
|
||||||
generateComprehensiveReport() {
|
|
||||||
// Ensure test results directory exists
|
|
||||||
if (!fs.existsSync(TEST_RESULTS_DIR)) {
|
|
||||||
fs.mkdirSync(TEST_RESULTS_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportPath = path.join(
|
|
||||||
TEST_RESULTS_DIR,
|
|
||||||
"performance-test-report.json",
|
|
||||||
);
|
|
||||||
fs.writeFileSync(reportPath, JSON.stringify(this.results, null, 2));
|
|
||||||
|
|
||||||
// Generate markdown report
|
|
||||||
this.generateMarkdownReport();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate markdown test report
|
|
||||||
*/
|
|
||||||
generateMarkdownReport() {
|
|
||||||
const reportPath = path.join(
|
|
||||||
TEST_RESULTS_DIR,
|
|
||||||
"performance-test-report.md",
|
|
||||||
);
|
|
||||||
|
|
||||||
let report = `# Performance Test Report\n\n`;
|
|
||||||
report += `**Generated:** ${this.results.timestamp}\n\n`;
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
report += `## Test Summary\n\n`;
|
|
||||||
report += `- **Total Tests:** ${this.results.summary.total}\n`;
|
|
||||||
report += `- **Passed:** ${this.results.summary.passed} ✅\n`;
|
|
||||||
report += `- **Failed:** ${this.results.summary.failed} ❌\n`;
|
|
||||||
report += `- **Warnings:** ${this.results.summary.warnings} ⚠️\n\n`;
|
|
||||||
|
|
||||||
// Bundle Analysis Results
|
|
||||||
if (Object.keys(this.results.bundleAnalysis).length > 0) {
|
|
||||||
report += `## Bundle Analysis\n\n`;
|
|
||||||
if (
|
|
||||||
this.results.bundleAnalysis.budgetViolations &&
|
|
||||||
this.results.bundleAnalysis.budgetViolations.length > 0
|
|
||||||
) {
|
|
||||||
report += `### Budget Violations\n\n`;
|
|
||||||
this.results.bundleAnalysis.budgetViolations.forEach((violation) => {
|
|
||||||
report += `- **${violation.file}**: ${
|
|
||||||
violation.currentSize
|
|
||||||
}KB (exceeds ${violation.maxSize}KB by ${
|
|
||||||
violation.overage
|
|
||||||
}KB) - ${violation.severity.toUpperCase()}\n`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
report += `✅ No bundle budget violations found\n\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Performance Monitoring Results
|
|
||||||
if (Object.keys(this.results.performanceMonitoring).length > 0) {
|
|
||||||
report += `## Performance Monitoring\n\n`;
|
|
||||||
if (
|
|
||||||
this.results.performanceMonitoring.budgetViolations &&
|
|
||||||
this.results.performanceMonitoring.budgetViolations.length > 0
|
|
||||||
) {
|
|
||||||
report += `### Budget Violations\n\n`;
|
|
||||||
this.results.performanceMonitoring.budgetViolations.forEach(
|
|
||||||
(violation) => {
|
|
||||||
report += `- **${violation.metric}**: ${
|
|
||||||
violation.current
|
|
||||||
} (exceeds ${
|
|
||||||
violation.budget
|
|
||||||
}) - ${violation.severity.toUpperCase()}\n`;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
report += `✅ No performance budget violations found\n\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web Vitals Results
|
|
||||||
if (Object.keys(this.results.webVitals).length > 0) {
|
|
||||||
report += `## Web Vitals Tracking\n\n`;
|
|
||||||
report += `✅ Web Vitals tracking setup complete\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lighthouse Results
|
|
||||||
if (Object.keys(this.results.lighthouse).length > 0) {
|
|
||||||
report += `## Lighthouse CI\n\n`;
|
|
||||||
report += `✅ Lighthouse CI completed successfully\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recommendations
|
|
||||||
report += `## Recommendations\n\n`;
|
|
||||||
report += `- Monitor bundle sizes regularly\n`;
|
|
||||||
report += `- Track Core Web Vitals in production\n`;
|
|
||||||
report += `- Run performance tests in CI/CD pipeline\n`;
|
|
||||||
report += `- Set up performance budgets and alerts\n`;
|
|
||||||
|
|
||||||
fs.writeFileSync(reportPath, report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run if called directly
|
|
||||||
if (require.main === module) {
|
|
||||||
const tester = new PerformanceTester();
|
|
||||||
tester.runTests().catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = PerformanceTester;
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Web Vitals Tracker
|
|
||||||
* Real-time monitoring of Core Web Vitals in production
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const WEB_VITALS_DIR = path.join(__dirname, "..", ".next", "web-vitals");
|
|
||||||
|
|
||||||
class WebVitalsTracker {
|
|
||||||
constructor() {
|
|
||||||
this.metrics = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
vitals: {
|
|
||||||
lcp: [],
|
|
||||||
fid: [],
|
|
||||||
cls: [],
|
|
||||||
fcp: [],
|
|
||||||
ttfb: [],
|
|
||||||
},
|
|
||||||
summary: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track Web Vitals from client-side
|
|
||||||
*/
|
|
||||||
trackWebVitals() {
|
|
||||||
const trackingCode = `
|
|
||||||
// Web Vitals Tracking Script
|
|
||||||
(function() {
|
|
||||||
// Import web-vitals library
|
|
||||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
|
||||||
const vitals = {};
|
|
||||||
|
|
||||||
// Track Largest Contentful Paint
|
|
||||||
getLCP((metric) => {
|
|
||||||
vitals.lcp = {
|
|
||||||
value: metric.value,
|
|
||||||
rating: metric.rating,
|
|
||||||
delta: metric.delta,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
sendVitals('lcp', vitals.lcp);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track First Input Delay
|
|
||||||
getFID((metric) => {
|
|
||||||
vitals.fid = {
|
|
||||||
value: metric.value,
|
|
||||||
rating: metric.rating,
|
|
||||||
delta: metric.delta,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
sendVitals('fid', vitals.fid);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track Cumulative Layout Shift
|
|
||||||
getCLS((metric) => {
|
|
||||||
vitals.cls = {
|
|
||||||
value: metric.value,
|
|
||||||
rating: metric.rating,
|
|
||||||
delta: metric.delta,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
sendVitals('cls', vitals.cls);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track First Contentful Paint
|
|
||||||
getFCP((metric) => {
|
|
||||||
vitals.fcp = {
|
|
||||||
value: metric.value,
|
|
||||||
rating: metric.rating,
|
|
||||||
delta: metric.delta,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
sendVitals('fcp', vitals.fcp);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track Time to First Byte
|
|
||||||
getTTFB((metric) => {
|
|
||||||
vitals.ttfb = {
|
|
||||||
value: metric.value,
|
|
||||||
rating: metric.rating,
|
|
||||||
delta: metric.delta,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
sendVitals('ttfb', vitals.ttfb);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send vitals to server
|
|
||||||
function sendVitals(metric, data) {
|
|
||||||
if (typeof window !== 'undefined' && window.fetch) {
|
|
||||||
fetch('/api/web-vitals', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
metric,
|
|
||||||
data,
|
|
||||||
url: window.location.href,
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
}).catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
|
|
||||||
return trackingCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create API endpoint for receiving Web Vitals
|
|
||||||
*/
|
|
||||||
createAPIEndpoint() {
|
|
||||||
const apiCode = `
|
|
||||||
// API endpoint for Web Vitals tracking
|
|
||||||
export default function handler(req, res) {
|
|
||||||
if (req.method !== 'POST') {
|
|
||||||
return res.status(405).json({ error: 'Method not allowed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { metric, data, url, userAgent, timestamp } = req.body;
|
|
||||||
|
|
||||||
// Store the metric data
|
|
||||||
const vitalsData = {
|
|
||||||
metric,
|
|
||||||
data,
|
|
||||||
url,
|
|
||||||
userAgent,
|
|
||||||
timestamp: new Date(timestamp).toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// In production, you would save this to a database
|
|
||||||
// For now, we'll log it
|
|
||||||
console.log('Web Vital received:', vitalsData);
|
|
||||||
|
|
||||||
res.status(200).json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing web vital:', error);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
return apiCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate Web Vitals dashboard
|
|
||||||
*/
|
|
||||||
generateDashboard() {
|
|
||||||
const dashboardCode = `
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
const WebVitalsDashboard = () => {
|
|
||||||
const [vitals, setVitals] = useState({
|
|
||||||
lcp: { value: 0, rating: 'unknown' },
|
|
||||||
fid: { value: 0, rating: 'unknown' },
|
|
||||||
cls: { value: 0, rating: 'unknown' },
|
|
||||||
fcp: { value: 0, rating: 'unknown' },
|
|
||||||
ttfb: { value: 0, rating: 'unknown' }
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// In a real implementation, you would fetch from your database
|
|
||||||
// For now, we'll use localStorage for demo purposes
|
|
||||||
const storedVitals = localStorage.getItem('web-vitals');
|
|
||||||
if (storedVitals) {
|
|
||||||
setVitals(JSON.parse(storedVitals));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getRatingColor = (rating) => {
|
|
||||||
switch (rating) {
|
|
||||||
case 'good': return 'text-green-600';
|
|
||||||
case 'needs-improvement': return 'text-yellow-600';
|
|
||||||
case 'poor': return 'text-red-600';
|
|
||||||
default: return 'text-gray-600';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRatingIcon = (rating) => {
|
|
||||||
switch (rating) {
|
|
||||||
case 'good': return '✅';
|
|
||||||
case 'needs-improvement': return '⚠️';
|
|
||||||
case 'poor': return '❌';
|
|
||||||
default: return '❓';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 bg-white rounded-lg shadow-lg">
|
|
||||||
<h2 className="text-2xl font-bold mb-6">Web Vitals Dashboard</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{Object.entries(vitals).map(([metric, data]) => (
|
|
||||||
<div key={metric} className="p-4 border rounded-lg">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
|
|
||||||
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
<div>Value: {data.value}ms</div>
|
|
||||||
<div className={\`font-medium \${getRatingColor(data.rating)}\`}>
|
|
||||||
Rating: {data.rating.replace('-', ' ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
|
||||||
<h3 className="font-semibold mb-2">Performance Guidelines</h3>
|
|
||||||
<ul className="text-sm space-y-1">
|
|
||||||
<li>• <strong>LCP:</strong> Good < 2.5s, Needs Improvement 2.5-4s, Poor > 4s</li>
|
|
||||||
<li>• <strong>FID:</strong> Good < 100ms, Needs Improvement 100-300ms, Poor > 300ms</li>
|
|
||||||
<li>• <strong>CLS:</strong> Good < 0.1, Needs Improvement 0.1-0.25, Poor > 0.25</li>
|
|
||||||
<li>• <strong>FCP:</strong> Good < 1.8s, Needs Improvement 1.8-3s, Poor > 3s</li>
|
|
||||||
<li>• <strong>TTFB:</strong> Good < 800ms, Needs Improvement 800-1800ms, Poor > 1800ms</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WebVitalsDashboard;
|
|
||||||
`;
|
|
||||||
|
|
||||||
return dashboardCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save Web Vitals data
|
|
||||||
*/
|
|
||||||
saveVitalsData(metric, data) {
|
|
||||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
|
||||||
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
|
|
||||||
let existingData = [];
|
|
||||||
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
try {
|
|
||||||
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Could not parse existing vitals data:", error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
existingData.push({
|
|
||||||
...data,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep only last 100 entries
|
|
||||||
if (existingData.length > 100) {
|
|
||||||
existingData = existingData.slice(-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate Web Vitals report
|
|
||||||
*/
|
|
||||||
generateReport() {
|
|
||||||
if (!fs.existsSync(WEB_VITALS_DIR)) {
|
|
||||||
console.log("No Web Vitals data found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = fs.readdirSync(WEB_VITALS_DIR);
|
|
||||||
const report = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
metrics: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
files.forEach((file) => {
|
|
||||||
if (file.endsWith(".json")) {
|
|
||||||
const metric = file.replace(".json", "");
|
|
||||||
const data = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data.length > 0) {
|
|
||||||
const values = data
|
|
||||||
.map((d) => d.value)
|
|
||||||
.filter((v) => v !== undefined);
|
|
||||||
const ratings = data
|
|
||||||
.map((d) => d.rating)
|
|
||||||
.filter((r) => r !== undefined);
|
|
||||||
|
|
||||||
report.metrics[metric] = {
|
|
||||||
count: data.length,
|
|
||||||
average:
|
|
||||||
values.length > 0
|
|
||||||
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
|
|
||||||
: 0,
|
|
||||||
min: values.length > 0 ? Math.min(...values) : 0,
|
|
||||||
max: values.length > 0 ? Math.max(...values) : 0,
|
|
||||||
goodCount: ratings.filter((r) => r === "good").length,
|
|
||||||
needsImprovementCount: ratings.filter(
|
|
||||||
(r) => r === "needs-improvement",
|
|
||||||
).length,
|
|
||||||
poorCount: ratings.filter((r) => r === "poor").length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const reportPath = path.join(WEB_VITALS_DIR, "report.json");
|
|
||||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
||||||
|
|
||||||
console.log("📊 Web Vitals report generated:", reportPath);
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run if called directly
|
|
||||||
if (require.main === module) {
|
|
||||||
const tracker = new WebVitalsTracker();
|
|
||||||
tracker.generateReport();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = WebVitalsTracker;
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import ContentThumbnailTemplate from "../../app/components/ContentThumbnailTemplate";
|
|
||||||
import RelatedArticles from "../../app/components/RelatedArticles";
|
|
||||||
|
|
||||||
// Mock Next.js navigation
|
|
||||||
const mockPush = vi.fn();
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: () => ({ push: mockPush }),
|
|
||||||
notFound: vi.fn(),
|
|
||||||
usePathname: vi.fn(() => "/"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Next.js Link to trigger navigation
|
|
||||||
vi.mock("next/link", () => ({
|
|
||||||
default: ({ children, href, ...props }) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
{...props}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
mockPush(href);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock asset utils
|
|
||||||
vi.mock("../../lib/assetUtils", () => ({
|
|
||||||
getAssetPath: vi.fn((asset) => `/assets/${asset}`),
|
|
||||||
ASSETS: {
|
|
||||||
CONTENT_THUMBNAIL_1: "Content_Thumbnail_1.svg",
|
|
||||||
CONTENT_THUMBNAIL_2: "Content_Thumbnail_2.svg",
|
|
||||||
CONTENT_THUMBNAIL_3: "Content_Thumbnail_3.svg",
|
|
||||||
CONTENT_ICON_1: "Content_Icon_1.svg",
|
|
||||||
CONTENT_ICON_2: "Content_Icon_2.svg",
|
|
||||||
CONTENT_ICON_3: "Content_Icon_3.svg",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockBlogPost = {
|
|
||||||
slug: "resolving-active-conflicts",
|
|
||||||
frontmatter: {
|
|
||||||
title: "Resolving Active Conflicts",
|
|
||||||
description:
|
|
||||||
"Practical steps for resolving conflicts while maintaining trust",
|
|
||||||
author: "Test Author",
|
|
||||||
date: "2025-04-15",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRelatedPosts = [
|
|
||||||
{
|
|
||||||
slug: "operational-security-mutual-aid",
|
|
||||||
frontmatter: {
|
|
||||||
title: "Operational Security for Mutual Aid",
|
|
||||||
description: "Tactics to protect members, secure communication",
|
|
||||||
author: "Test Author",
|
|
||||||
date: "2025-04-14",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "making-decisions-without-hierarchy",
|
|
||||||
frontmatter: {
|
|
||||||
title: "Making Decisions Without Hierarchy",
|
|
||||||
description:
|
|
||||||
"A brief guide to collaborative nonhierarchical decision making",
|
|
||||||
author: "Test Author",
|
|
||||||
date: "2025-04-13",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("Blog Navigation E2E", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ContentThumbnailTemplate Navigation", () => {
|
|
||||||
it("should navigate to blog post when thumbnail is clicked", () => {
|
|
||||||
render(<ContentThumbnailTemplate post={mockBlogPost} />);
|
|
||||||
|
|
||||||
// Find the thumbnail link
|
|
||||||
const thumbnailLink = screen.getByRole("link");
|
|
||||||
expect(thumbnailLink).toBeInTheDocument();
|
|
||||||
expect(thumbnailLink).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"/blog/resolving-active-conflicts",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Click the thumbnail
|
|
||||||
fireEvent.click(thumbnailLink);
|
|
||||||
|
|
||||||
// Verify navigation was called
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/blog/resolving-active-conflicts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display correct post information", () => {
|
|
||||||
render(<ContentThumbnailTemplate post={mockBlogPost} />);
|
|
||||||
|
|
||||||
// Verify post content is displayed
|
|
||||||
expect(
|
|
||||||
screen.getByText("Resolving Active Conflicts"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText(
|
|
||||||
"Practical steps for resolving conflicts while maintaining trust",
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Test Author")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("April 2025")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render with correct variant based on screen size", () => {
|
|
||||||
render(<ContentThumbnailTemplate post={mockBlogPost} />);
|
|
||||||
|
|
||||||
// Verify the thumbnail container exists
|
|
||||||
const thumbnailContainer = screen.getByRole("link").closest("div");
|
|
||||||
expect(thumbnailContainer).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("RelatedArticles Navigation", () => {
|
|
||||||
it("should display related articles with correct links", () => {
|
|
||||||
render(<RelatedArticles relatedPosts={mockRelatedPosts} />);
|
|
||||||
|
|
||||||
// Verify related articles are displayed
|
|
||||||
expect(
|
|
||||||
screen.getByText("Operational Security for Mutual Aid"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("Making Decisions Without Hierarchy"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Verify links are present
|
|
||||||
const relatedLinks = screen.getAllByRole("link");
|
|
||||||
expect(relatedLinks).toHaveLength(2);
|
|
||||||
expect(relatedLinks[0]).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"/blog/operational-security-mutual-aid",
|
|
||||||
);
|
|
||||||
expect(relatedLinks[1]).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"/blog/making-decisions-without-hierarchy",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should navigate to related article when clicked", () => {
|
|
||||||
render(<RelatedArticles relatedPosts={mockRelatedPosts} />);
|
|
||||||
|
|
||||||
// Find and click first related article
|
|
||||||
const firstRelatedLink = screen
|
|
||||||
.getByText("Operational Security for Mutual Aid")
|
|
||||||
.closest("a");
|
|
||||||
expect(firstRelatedLink).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(firstRelatedLink);
|
|
||||||
|
|
||||||
// Verify navigation was called
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(
|
|
||||||
"/blog/operational-security-mutual-aid",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty related posts gracefully", () => {
|
|
||||||
const { container } = render(<RelatedArticles relatedPosts={[]} />);
|
|
||||||
|
|
||||||
// Should not crash and should render nothing (component returns null)
|
|
||||||
expect(container.firstChild).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Navigation Flow", () => {
|
|
||||||
it("should complete navigation flow: thumbnail → related article", () => {
|
|
||||||
// Render thumbnail
|
|
||||||
const { rerender } = render(
|
|
||||||
<ContentThumbnailTemplate post={mockBlogPost} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Click thumbnail
|
|
||||||
const thumbnailLink = screen.getByRole("link");
|
|
||||||
fireEvent.click(thumbnailLink);
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/blog/resolving-active-conflicts");
|
|
||||||
|
|
||||||
// Clear mocks and render related articles
|
|
||||||
vi.clearAllMocks();
|
|
||||||
rerender(<RelatedArticles relatedPosts={mockRelatedPosts} />);
|
|
||||||
|
|
||||||
// Click related article
|
|
||||||
const relatedLink = screen
|
|
||||||
.getByText("Operational Security for Mutual Aid")
|
|
||||||
.closest("a");
|
|
||||||
fireEvent.click(relatedLink);
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(
|
|
||||||
"/blog/operational-security-mutual-aid",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import ContentBanner from "../../app/components/ContentBanner";
|
|
||||||
import AskOrganizer from "../../app/components/AskOrganizer";
|
|
||||||
|
|
||||||
// Mock Next.js navigation
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: () => ({ push: vi.fn() }),
|
|
||||||
notFound: vi.fn(),
|
|
||||||
usePathname: vi.fn(() => "/blog/test-post"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock asset utils
|
|
||||||
vi.mock("../../lib/assetUtils", () => ({
|
|
||||||
getAssetPath: vi.fn((asset) => `/assets/${asset}`),
|
|
||||||
ASSETS: {
|
|
||||||
CONTENT_BANNER_1: "Content_Banner_1.svg",
|
|
||||||
CONTENT_BANNER_2: "Content_Banner_2.svg",
|
|
||||||
CONTENT_SHAPE_1: "Content_Shape_1.svg",
|
|
||||||
CONTENT_SHAPE_2: "Content_Shape_2.svg",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockBlogPost = {
|
|
||||||
slug: "test-article",
|
|
||||||
frontmatter: {
|
|
||||||
title: "Test Article Title",
|
|
||||||
description: "This is a test article description",
|
|
||||||
author: "Test Author",
|
|
||||||
date: "2025-04-15",
|
|
||||||
},
|
|
||||||
htmlContent:
|
|
||||||
"<p>This is the main content of the test article.</p><p>It has multiple paragraphs.</p>",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Content Page Rendering E2E", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ContentBanner Component", () => {
|
|
||||||
it("should render blog post banner with correct information", () => {
|
|
||||||
render(<ContentBanner post={mockBlogPost} />);
|
|
||||||
|
|
||||||
// Verify banner content
|
|
||||||
expect(screen.getByText("Test Article Title")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("This is a test article description"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Test Author")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("April 2025")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render with proper semantic structure", () => {
|
|
||||||
render(<ContentBanner post={mockBlogPost} />);
|
|
||||||
|
|
||||||
// Verify semantic HTML structure - ContentBanner doesn't have role="banner"
|
|
||||||
const container = screen.getByText("Test Article Title").closest("div");
|
|
||||||
expect(container).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Verify headings hierarchy
|
|
||||||
const h3 = screen.getByRole("heading", { level: 3 });
|
|
||||||
expect(h3).toHaveTextContent("Test Article Title");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle different blog posts with different content", () => {
|
|
||||||
const differentPost = {
|
|
||||||
...mockBlogPost,
|
|
||||||
frontmatter: {
|
|
||||||
...mockBlogPost.frontmatter,
|
|
||||||
title: "Different Article Title",
|
|
||||||
description: "Different description",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<ContentBanner post={differentPost} />);
|
|
||||||
|
|
||||||
// Verify different content is rendered
|
|
||||||
expect(screen.getByText("Different Article Title")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Different description")).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Verify old content is not present
|
|
||||||
expect(screen.queryByText("Test Article Title")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("AskOrganizer Component", () => {
|
|
||||||
it("should render ask organizer with correct content", () => {
|
|
||||||
render(
|
|
||||||
<AskOrganizer
|
|
||||||
title="Still have questions?"
|
|
||||||
subtitle="Get help from our community organizers"
|
|
||||||
description="We're here to help you with any questions or concerns."
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify ask organizer content
|
|
||||||
expect(screen.getByText("Still have questions?")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("Get help from our community organizers"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("link", { name: /ask an organizer/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render with inverse variant", () => {
|
|
||||||
render(
|
|
||||||
<AskOrganizer
|
|
||||||
variant="inverse"
|
|
||||||
title="Still have questions?"
|
|
||||||
subtitle="Get help from our community organizers"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify ask organizer content is still present
|
|
||||||
expect(screen.getByText("Still have questions?")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("Get help from our community organizers"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("link", { name: /ask an organizer/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have proper accessibility attributes", () => {
|
|
||||||
render(<AskOrganizer />);
|
|
||||||
|
|
||||||
// Verify link is accessible (Button component renders as a link)
|
|
||||||
const link = screen.getByRole("link", { name: /ask an organizer/i });
|
|
||||||
expect(link).toBeInTheDocument();
|
|
||||||
expect(link).toHaveAttribute("href", "#");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Component Integration", () => {
|
|
||||||
it("should render multiple components together", () => {
|
|
||||||
render(
|
|
||||||
<div>
|
|
||||||
<ContentBanner post={mockBlogPost} />
|
|
||||||
<AskOrganizer
|
|
||||||
title="Still have questions?"
|
|
||||||
subtitle="Get help from our community organizers"
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify both components are rendered
|
|
||||||
expect(screen.getByText("Test Article Title")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Still have questions?")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain proper semantic structure when combined", () => {
|
|
||||||
render(
|
|
||||||
<main>
|
|
||||||
<ContentBanner post={mockBlogPost} />
|
|
||||||
<AskOrganizer
|
|
||||||
title="Still have questions?"
|
|
||||||
subtitle="Get help from our community organizers"
|
|
||||||
/>
|
|
||||||
</main>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify semantic structure
|
|
||||||
expect(screen.getByRole("main")).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("region")).toBeInTheDocument(); // AskOrganizer has role="region"
|
|
||||||
|
|
||||||
// Verify headings hierarchy
|
|
||||||
const h3 = screen.getByRole("heading", { level: 3 });
|
|
||||||
expect(h3).toHaveTextContent("Test Article Title");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import Logo from "../../app/components/Logo";
|
|
||||||
|
|
||||||
// Mock Next.js Link component
|
|
||||||
vi.mock("next/link", () => ({
|
|
||||||
default: ({ children, href, ...props }) => (
|
|
||||||
<a href={href} {...props}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock asset utils
|
|
||||||
vi.mock("../../lib/assetUtils", () => ({
|
|
||||||
getAssetPath: vi.fn((asset) => `/assets/${asset}`),
|
|
||||||
ASSETS: {
|
|
||||||
LOGO: "CommunityRule_Logo.svg",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Logo Navigation E2E", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should navigate to homepage when logo is clicked", () => {
|
|
||||||
render(<Logo />);
|
|
||||||
|
|
||||||
// Find the logo link
|
|
||||||
const logoLink = screen.getByRole("link", { name: /communityrule logo/i });
|
|
||||||
expect(logoLink).toBeInTheDocument();
|
|
||||||
expect(logoLink).toHaveAttribute("href", "/");
|
|
||||||
|
|
||||||
// Verify the link is clickable (Next.js Link renders as <a> tag)
|
|
||||||
expect(logoLink.tagName).toBe("A");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have proper accessibility attributes", () => {
|
|
||||||
render(<Logo />);
|
|
||||||
|
|
||||||
const logoLink = screen.getByRole("link", { name: /communityrule logo/i });
|
|
||||||
expect(logoLink).toHaveAttribute("aria-label", "CommunityRule Logo");
|
|
||||||
expect(logoLink).toHaveAttribute("href", "/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render logo image correctly", () => {
|
|
||||||
render(<Logo />);
|
|
||||||
|
|
||||||
// The image has aria-hidden="true" so we need to find it by alt text
|
|
||||||
const logoImage = screen.getByAltText("CommunityRule Logo Icon");
|
|
||||||
expect(logoImage).toBeInTheDocument();
|
|
||||||
expect(logoImage).toHaveAttribute("src", "/assets/CommunityRule_Logo.svg");
|
|
||||||
expect(logoImage).toHaveAttribute("alt", "CommunityRule Logo Icon");
|
|
||||||
expect(logoImage).toHaveAttribute("aria-hidden", "true");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Critical User Journeys", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("complete user journey: learn about CommunityRule", async ({ page }) => {
|
||||||
|
// 1. User lands on homepage
|
||||||
|
await expect(page.locator("text=Collaborate")).toBeVisible();
|
||||||
|
|
||||||
|
// 2. User reads hero section
|
||||||
|
await expect(
|
||||||
|
page.locator("text=Help your community make important decisions"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// 3. User clicks CTA to learn more
|
||||||
|
const learnButton = page
|
||||||
|
.locator('button:has-text("Learn how CommunityRule works")')
|
||||||
|
.first();
|
||||||
|
if ((await learnButton.count()) > 0 && (await learnButton.isVisible())) {
|
||||||
|
await learnButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. User scrolls to numbered cards section
|
||||||
|
// Note: SectionHeader shows "How CommunityRule works" on mobile, "How CommunityRule helps" on desktop
|
||||||
|
const howItWorksHeading = page.locator(
|
||||||
|
'h2:has-text("How CommunityRule works"), h2:has-text("How CommunityRule helps")',
|
||||||
|
);
|
||||||
|
await expect(howItWorksHeading).toBeVisible();
|
||||||
|
|
||||||
|
// 5. User reads the process steps
|
||||||
|
await expect(
|
||||||
|
page.locator("text=Document how your community makes decisions"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator("text=Build an operating manual for a successful community"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
"text=Get a link to your manual for your group to review and evolve",
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// 6. User explores rule templates
|
||||||
|
await page.locator("text=Consensus clusters").first().click();
|
||||||
|
await page.locator("text=Consensus").nth(1).click();
|
||||||
|
await page.locator("text=Elected Board").first().click();
|
||||||
|
await page.locator("text=Petition").first().click();
|
||||||
|
|
||||||
|
// 7. User checks out features
|
||||||
|
const features = [
|
||||||
|
"Decision-making support",
|
||||||
|
"Values alignment exercises",
|
||||||
|
"Membership guidance",
|
||||||
|
"Conflict resolution tools",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
const featureElement = page.locator(`text=${feature}`);
|
||||||
|
if (
|
||||||
|
(await featureElement.count()) > 0 &&
|
||||||
|
(await featureElement.first().isVisible())
|
||||||
|
) {
|
||||||
|
await featureElement.first().click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. User reads testimonial
|
||||||
|
await expect(page.locator("text=Jo Freeman")).toBeVisible();
|
||||||
|
|
||||||
|
// 9. User decides to contact organizer
|
||||||
|
const askButton = page.locator(
|
||||||
|
'a:has-text("Ask an organizer"), button:has-text("Ask an organizer")',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(await askButton.count()) > 0 &&
|
||||||
|
(await askButton.first().isVisible())
|
||||||
|
) {
|
||||||
|
await askButton.first().click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("homepage loads successfully with all sections", async ({ page }) => {
|
||||||
|
// Check page title
|
||||||
|
await expect(page).toHaveTitle(/CommunityRule/);
|
||||||
|
|
||||||
|
// Check main sections are present
|
||||||
|
await expect(
|
||||||
|
page.locator("h1, h2").filter({ hasText: "Collaborate" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
const howItWorksHeading = page.locator(
|
||||||
|
'h2:has-text("How CommunityRule works"), h2:has-text("How CommunityRule helps")',
|
||||||
|
);
|
||||||
|
await expect(howItWorksHeading).toBeVisible();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator("h1").filter({ hasText: "We've got your back" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Check key components are rendered
|
||||||
|
await expect(page.locator('img[alt="Hero illustration"]')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator("text=Trusted by leading cooperators"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator("text=Jo Freeman")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("feature grid section functionality", async ({ page }) => {
|
||||||
|
// Check section header
|
||||||
|
await expect(
|
||||||
|
page.locator('h1:has-text("We\'ve got your back")'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
"text=Use our toolkit to improve, document, and evolve your organization",
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Check all four feature cards - FeatureGrid uses section with grid layout
|
||||||
|
const featureSection = page.locator(
|
||||||
|
'section[aria-label="Feature tools and services"], section:has-text("We\'ve got your back")',
|
||||||
|
);
|
||||||
|
await expect(featureSection.locator("text=Decision-making")).toBeVisible();
|
||||||
|
await expect(featureSection.locator("text=Values alignment")).toBeVisible();
|
||||||
|
await expect(featureSection.locator("text=Membership")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
featureSection.locator("text=Conflict resolution"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Check feature links - MiniCard components render as <a> tags with href="#..."
|
||||||
|
// There are 4 feature cards + 1 "Learn more" link = 5 total links
|
||||||
|
// We check for the specific feature card links
|
||||||
|
await expect(
|
||||||
|
featureSection.locator('a[href="#decision-making"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
featureSection.locator('a[href="#values-alignment"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
featureSection.locator('a[href="#membership-guidance"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
featureSection.locator('a[href="#conflict-resolution"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Test feature card interactions
|
||||||
|
await page.locator('a[href="#decision-making"]').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("header navigation functionality", async ({ page }) => {
|
||||||
|
// Check header is present
|
||||||
|
await expect(page.locator("header")).toBeVisible();
|
||||||
|
|
||||||
|
// Test logo click
|
||||||
|
const logoLinks = page.locator('a[aria-label="CommunityRule Logo"]');
|
||||||
|
const logoCount = await logoLinks.count();
|
||||||
|
expect(logoCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
let visibleLogo = null;
|
||||||
|
for (let i = 0; i < logoCount; i++) {
|
||||||
|
const logo = logoLinks.nth(i);
|
||||||
|
if (await logo.isVisible()) {
|
||||||
|
visibleLogo = logo;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(visibleLogo).not.toBeNull();
|
||||||
|
await visibleLogo.click();
|
||||||
|
await expect(page).toHaveURL(/\/#?$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keyboard navigation and accessibility", async ({ page }) => {
|
||||||
|
// Test tab navigation
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await expect(page.locator(":focus")).toBeVisible();
|
||||||
|
|
||||||
|
// Navigate through interactive elements
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
|
||||||
|
// Test Enter key on buttons
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
// Basic accessibility checks
|
||||||
|
const html = page.locator("html");
|
||||||
|
const lang = await html.getAttribute("lang");
|
||||||
|
expect(lang).toBeTruthy();
|
||||||
|
|
||||||
|
// Check for main heading
|
||||||
|
const h1 = page.locator("h1").first();
|
||||||
|
await expect(h1).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("responsive design behavior", async ({ page }) => {
|
||||||
|
// Test mobile viewport
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await expect(
|
||||||
|
page.locator("h1, h2").filter({ hasText: "Collaborate" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Test tablet viewport
|
||||||
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
|
await expect(
|
||||||
|
page.locator("h1, h2").filter({ hasText: "Collaborate" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Test desktop viewport
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await expect(
|
||||||
|
page.locator("h1, h2").filter({ hasText: "Collaborate" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,14 +6,16 @@ test.describe("Edge Cases and Error Scenarios", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("handles slow network conditions", async ({ page }) => {
|
test("handles slow network conditions", async ({ page }) => {
|
||||||
// Simulate slow network
|
// Page is already loaded from beforeEach
|
||||||
|
// Simulate slow network for any subsequent requests
|
||||||
await page.route("**/*", (route) => {
|
await page.route("**/*", (route) => {
|
||||||
// Add 2 second delay to all requests
|
// Add 2 second delay to all requests
|
||||||
setTimeout(() => route.continue(), 2000);
|
setTimeout(() => route.continue(), 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload page with slow network
|
// Navigate to a new page to test slow network conditions
|
||||||
await page.reload();
|
// Use a fresh navigation instead of reload to avoid Web Inspector issues
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||||
|
|
||||||
// Page should still load eventually
|
// Page should still load eventually
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible({
|
await expect(page.locator("text=Collaborate")).toBeVisible({
|
||||||
@@ -22,213 +24,23 @@ test.describe("Edge Cases and Error Scenarios", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("handles offline mode gracefully", async ({ page }) => {
|
test("handles offline mode gracefully", async ({ page }) => {
|
||||||
// Note: page.setOffline() is not available in current Playwright version
|
// Page is already loaded from beforeEach, so we can test offline behavior
|
||||||
// This test would require network interception to simulate offline mode
|
// without reloading (which is blocked by Web Inspector in local environments)
|
||||||
// For now, we'll test that the page loads and functions normally
|
|
||||||
|
|
||||||
// Page should function normally
|
// Simulate offline mode by blocking all network requests
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
await page.route("**/*", (route) => {
|
||||||
const learnButtons = page.locator(
|
route.abort();
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles rapid user interactions", async ({ page }) => {
|
|
||||||
// Rapidly click visible buttons
|
|
||||||
const buttons = page.locator("button");
|
|
||||||
const buttonCount = await buttons.count();
|
|
||||||
let clickedCount = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount && clickedCount < 3; i++) {
|
|
||||||
const button = buttons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
await button.click();
|
|
||||||
await page.waitForTimeout(100); // Very short delay
|
|
||||||
clickedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page should remain stable
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles rapid scrolling", async ({ page }) => {
|
|
||||||
// Rapid scroll to bottom
|
|
||||||
await page.evaluate(() => {
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
window.scrollTo(0, document.body.scrollHeight * (i / 10));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should end up at bottom - use a more specific selector
|
// Verify page content is still visible (cached content should remain)
|
||||||
await expect(page.locator("footer").first()).toBeVisible();
|
// This tests that the page doesn't crash when network requests fail
|
||||||
});
|
const body = page.locator("body");
|
||||||
|
await expect(body).toBeVisible();
|
||||||
|
|
||||||
test("handles viewport size changes", async ({ page }) => {
|
// Verify key content is still accessible
|
||||||
// Rapidly change viewport sizes
|
|
||||||
const viewports = [
|
|
||||||
{ width: 375, height: 667 },
|
|
||||||
{ width: 768, height: 1024 },
|
|
||||||
{ width: 1440, height: 900 },
|
|
||||||
{ width: 1920, height: 1080 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const viewport of viewports) {
|
|
||||||
await page.setViewportSize(viewport);
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Content should remain visible
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles browser back/forward navigation", async ({ page }) => {
|
|
||||||
// Navigate to a section
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
|
|
||||||
// Since the button click doesn't navigate to a new page,
|
|
||||||
// we'll test that the page handles back/forward gracefully
|
|
||||||
await page.goBack();
|
|
||||||
await page.goForward();
|
|
||||||
|
|
||||||
// Should still have content
|
|
||||||
await expect(page.locator("body")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles page refresh during interactions", async ({ page }) => {
|
|
||||||
// Start an interaction
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
|
|
||||||
// Refresh page during interaction
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Should reload successfully
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
await expect(page.locator("text=Collaborate")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles multiple browser tabs", async ({ page, context }) => {
|
|
||||||
// Open multiple tabs
|
|
||||||
const page1 = await context.newPage();
|
|
||||||
const page2 = await context.newPage();
|
|
||||||
|
|
||||||
// Navigate all tabs to homepage
|
|
||||||
await page1.goto("/");
|
|
||||||
await page2.goto("/");
|
|
||||||
|
|
||||||
// Interact with each tab - find the first visible button
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
|
|
||||||
await page1.locator("text=Consensus clusters").click();
|
|
||||||
|
|
||||||
// Find the first visible "Ask an organizer" link (it's an <a> tag, not a button)
|
|
||||||
const askLinks = page2.locator('a:has-text("Ask an organizer")');
|
|
||||||
const askLinkCount = await askLinks.count();
|
|
||||||
let visibleAskLink = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < askLinkCount; i++) {
|
|
||||||
const link = askLinks.nth(i);
|
|
||||||
if (await link.isVisible()) {
|
|
||||||
visibleAskLink = link;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleAskLink) {
|
|
||||||
throw new Error('No visible "Ask an organizer" link found');
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleAskLink.click();
|
|
||||||
|
|
||||||
// All tabs should work independently
|
|
||||||
await expect(
|
|
||||||
page.locator('h2:has-text("How CommunityRule works")'),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page1.locator("text=Consensus clusters")).toBeVisible();
|
|
||||||
await expect(page2.locator("text=Still have questions?")).toBeVisible();
|
|
||||||
|
|
||||||
// Close extra tabs
|
|
||||||
await page1.close();
|
|
||||||
await page2.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles JavaScript errors gracefully", async ({ page }) => {
|
test("handles JavaScript errors gracefully", async ({ page }) => {
|
||||||
// Inject a JavaScript error
|
// Inject a JavaScript error
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -248,6 +60,7 @@ test.describe("Edge Cases and Error Scenarios", () => {
|
|||||||
|
|
||||||
// Page should continue to function
|
// Page should continue to function
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
await expect(page.locator("text=Collaborate")).toBeVisible();
|
||||||
|
|
||||||
const learnButtons = page.locator(
|
const learnButtons = page.locator(
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
'button:has-text("Learn how CommunityRule works")',
|
||||||
);
|
);
|
||||||
@@ -277,345 +90,13 @@ test.describe("Edge Cases and Error Scenarios", () => {
|
|||||||
route.abort();
|
route.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload page
|
// Navigate to a new page to test missing images
|
||||||
await page.reload();
|
// Use a fresh navigation instead of reload to avoid Web Inspector issues
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
// Page should still function without images
|
// Page should still function without images
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
await expect(page.locator("text=Collaborate")).toBeVisible();
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles CSS loading failures", async ({ page }) => {
|
|
||||||
// Block CSS requests
|
|
||||||
await page.route("**/*.css", (route) => {
|
|
||||||
route.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reload page
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Page should still function without styles
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles font loading failures", async ({ page }) => {
|
|
||||||
// Block font requests
|
|
||||||
await page.route("**/*.{woff,woff2,ttf,otf}", (route) => {
|
|
||||||
route.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reload page
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Page should still function with fallback fonts
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles memory pressure", async ({ page }) => {
|
|
||||||
// Simulate memory pressure by creating many elements
|
|
||||||
await page.evaluate(() => {
|
|
||||||
// Create temporary elements to simulate memory usage
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.textContent = `Test element ${i}`;
|
|
||||||
document.body.appendChild(div);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
setTimeout(() => {
|
|
||||||
const testElements = document.querySelectorAll(
|
|
||||||
'div[textContent*="Test element"]',
|
|
||||||
);
|
|
||||||
testElements.forEach((el) => el.remove());
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Page should remain functional
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles long content gracefully", async ({ page }) => {
|
|
||||||
// Add a lot of content to test scrolling performance
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const container = document.createElement("div");
|
|
||||||
container.style.height = "10000px";
|
|
||||||
container.style.background = "linear-gradient(red, blue)";
|
|
||||||
document.body.appendChild(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll through the content
|
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
||||||
|
|
||||||
// Should handle long content without issues
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles focus management", async ({ page }) => {
|
|
||||||
// Test focus trapping and management
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await expect(page.locator(":focus")).toBeVisible();
|
|
||||||
|
|
||||||
// Navigate through focusable elements
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await expect(page.locator(":focus")).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Shift+Tab for reverse navigation
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
await page.keyboard.press("Shift+Tab");
|
|
||||||
await expect(page.locator(":focus")).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles keyboard shortcuts", async ({ page }) => {
|
|
||||||
// Test common keyboard shortcuts
|
|
||||||
await page.keyboard.press("Home");
|
|
||||||
await page.keyboard.press("End");
|
|
||||||
await page.keyboard.press("PageUp");
|
|
||||||
await page.keyboard.press("PageDown");
|
|
||||||
|
|
||||||
// Page should handle shortcuts gracefully
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles copy/paste operations", async ({ page }) => {
|
|
||||||
// Test text selection and copy
|
|
||||||
await page.locator("text=Collaborate").selectText();
|
|
||||||
await page.keyboard.press("Control+c");
|
|
||||||
|
|
||||||
// Test paste (should work in input fields if any)
|
|
||||||
const inputs = page.locator("input, textarea");
|
|
||||||
if ((await inputs.count()) > 0) {
|
|
||||||
await inputs.first().click();
|
|
||||||
await page.keyboard.press("Control+v");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles right-click context menu", async ({ page }) => {
|
|
||||||
// Test right-click on various elements
|
|
||||||
await page.locator("text=Collaborate").click({ button: "right" });
|
|
||||||
|
|
||||||
// Find visible button for right-click
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleButton) {
|
|
||||||
await visibleButton.click({ button: "right" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to right-click on a visible image if it exists
|
|
||||||
const images = page.locator("img");
|
|
||||||
const imageCount = await images.count();
|
|
||||||
let visibleImage = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < imageCount; i++) {
|
|
||||||
const image = images.nth(i);
|
|
||||||
if (await image.isVisible()) {
|
|
||||||
visibleImage = image;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleImage) {
|
|
||||||
await visibleImage.click({ button: "right" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should handle right-clicks gracefully
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles drag and drop operations", async ({ page }) => {
|
|
||||||
// Test drag and drop (if applicable)
|
|
||||||
const draggableElements = page.locator('[draggable="true"]');
|
|
||||||
const dropZones = page.locator('[data-testid*="drop"], [class*="drop"]');
|
|
||||||
|
|
||||||
if (
|
|
||||||
(await draggableElements.count()) > 0 &&
|
|
||||||
(await dropZones.count()) > 0
|
|
||||||
) {
|
|
||||||
await draggableElements.first().dragTo(dropZones.first());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page should handle drag operations gracefully
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles print functionality", async ({ page }) => {
|
|
||||||
// Test print functionality
|
|
||||||
await page.evaluate(() => {
|
|
||||||
// Mock print function
|
|
||||||
window.print = () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger print
|
|
||||||
await page.keyboard.press("Control+p");
|
|
||||||
|
|
||||||
// Should handle print gracefully
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles browser zoom", async ({ page }) => {
|
|
||||||
// Test different zoom levels
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.body.style.zoom = "0.5";
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.body.style.zoom = "2.0";
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
|
|
||||||
// Reset zoom
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.body.style.zoom = "1.0";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles high contrast mode", async ({ page }) => {
|
|
||||||
// Simulate high contrast mode
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.body.style.filter = "contrast(200%)";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Content should remain readable
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
|
|
||||||
// Reset contrast
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.body.style.filter = "none";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles reduced motion preferences", async ({ page }) => {
|
|
||||||
// Simulate reduced motion preference
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.documentElement.style.setProperty(
|
|
||||||
"--prefers-reduced-motion",
|
|
||||||
"reduce",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Page should respect reduced motion
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
const learnButtons = page.locator(
|
const learnButtons = page.locator(
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
'button:has-text("Learn how CommunityRule works")',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,485 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Homepage", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("homepage loads successfully with all sections", async ({ page }) => {
|
|
||||||
// Check page title and meta
|
|
||||||
await expect(page).toHaveTitle(/CommunityRule/);
|
|
||||||
|
|
||||||
// Check main sections are present
|
|
||||||
await expect(
|
|
||||||
page.locator("h1, h2").filter({ hasText: "Collaborate" }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("h2").filter({ hasText: "How CommunityRule works" }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("h1").filter({ hasText: "We've got your back" }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check key components are rendered
|
|
||||||
await expect(page.locator('img[alt="Hero illustration"]')).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Trusted by leading cooperators"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.locator("text=Jo Freeman")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hero banner section functionality", async ({ page }) => {
|
|
||||||
// Check hero content
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
await expect(page.locator("text=with clarity")).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Help your community make important decisions"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check CTA button
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(visibleButton).toBeEnabled();
|
|
||||||
|
|
||||||
// Test button interaction
|
|
||||||
await visibleButton.click();
|
|
||||||
// Should scroll to the numbered cards section
|
|
||||||
await expect(
|
|
||||||
page.locator('h2:has-text("How CommunityRule works")'),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logo wall section displays correctly", async ({ page }) => {
|
|
||||||
// Check section label
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Trusted by leading cooperators"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check logos are present
|
|
||||||
await expect(page.locator('img[alt="Food Not Bombs"]')).toBeVisible();
|
|
||||||
await expect(page.locator('img[alt="Start COOP"]')).toBeVisible();
|
|
||||||
await expect(page.locator('img[alt="Metagov"]')).toBeVisible();
|
|
||||||
await expect(page.locator('img[alt="Open Civics"]')).toBeVisible();
|
|
||||||
await expect(page.locator('img[alt="Mutual Aid CO"]')).toBeVisible();
|
|
||||||
await expect(page.locator('img[alt="CU Boulder"]')).toBeVisible();
|
|
||||||
|
|
||||||
// Check logos have proper attributes
|
|
||||||
const logos = page.locator('img[alt*="Logo"]');
|
|
||||||
const logoCount = await logos.count();
|
|
||||||
expect(logoCount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Test hover effects (visual test)
|
|
||||||
await page.locator('img[alt="Food Not Bombs"]').hover();
|
|
||||||
// Should see hover state (opacity change)
|
|
||||||
});
|
|
||||||
|
|
||||||
test("numbered cards section functionality", async ({ page }) => {
|
|
||||||
// Check section header
|
|
||||||
await expect(
|
|
||||||
page.locator('h2:has-text("How CommunityRule works")'),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Here's a quick overview of the process"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check all three cards are present
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Document how your community makes decisions"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Build an operating manual for a successful community"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator(
|
|
||||||
"text=Get a link to your manual for your group to review and evolve",
|
|
||||||
),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check numbered indicators - target the specific numbered cards section
|
|
||||||
const numberedCardsSection = page
|
|
||||||
.locator("section")
|
|
||||||
.filter({ has: page.locator('h2:has-text("How CommunityRule works")') });
|
|
||||||
await expect(
|
|
||||||
numberedCardsSection.locator("span").filter({ hasText: "1" }).first(),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
numberedCardsSection.locator("span").filter({ hasText: "2" }).first(),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
numberedCardsSection.locator("span").filter({ hasText: "3" }).first(),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check CTA buttons
|
|
||||||
const createButtons = page.locator(
|
|
||||||
'button:has-text("Create CommunityRule")',
|
|
||||||
);
|
|
||||||
const createButtonCount = await createButtons.count();
|
|
||||||
let visibleCreateButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < createButtonCount; i++) {
|
|
||||||
const button = createButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleCreateButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleCreateButton) {
|
|
||||||
await expect(visibleCreateButton).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for responsive button visibility
|
|
||||||
const seeHowItWorksButton = page.locator(
|
|
||||||
'button:has-text("See how it works")',
|
|
||||||
);
|
|
||||||
const createCommunityRuleButton = page.locator(
|
|
||||||
'button:has-text("Create CommunityRule")',
|
|
||||||
);
|
|
||||||
|
|
||||||
// On mobile, "Create CommunityRule" should be visible, "See how it works" should be hidden
|
|
||||||
// On desktop, "See how it works" should be visible, "Create CommunityRule" should be hidden
|
|
||||||
const viewport = page.viewportSize();
|
|
||||||
if (viewport && viewport.width < 1024) {
|
|
||||||
// Mobile viewport
|
|
||||||
await expect(createCommunityRuleButton).toBeVisible();
|
|
||||||
await expect(seeHowItWorksButton).toBeHidden();
|
|
||||||
} else {
|
|
||||||
// Desktop viewport
|
|
||||||
await expect(seeHowItWorksButton).toBeVisible();
|
|
||||||
await expect(createCommunityRuleButton).toBeHidden();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rule stack section interactions", async ({ page }) => {
|
|
||||||
// Check all four rule cards are present
|
|
||||||
await expect(page.locator("text=Consensus clusters")).toBeVisible();
|
|
||||||
await expect(page.locator("text=Consensus clusters")).toBeVisible();
|
|
||||||
await expect(page.locator("text=Elected Board").first()).toBeVisible();
|
|
||||||
await expect(page.locator("text=Petition")).toBeVisible();
|
|
||||||
|
|
||||||
// Check rule descriptions
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Units called Circles have the ability to decide"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Decisions that affect the group collectively"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=An elected board determines policies"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=All participants can propose and vote"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Test card interactions
|
|
||||||
const consensusCard = page.locator('[aria-label*="Consensus clusters"]');
|
|
||||||
await consensusCard.click();
|
|
||||||
// Should trigger analytics tracking (console log in test environment)
|
|
||||||
|
|
||||||
// Check "See all templates" button
|
|
||||||
await expect(
|
|
||||||
page.locator('button:has-text("See all templates")'),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("feature grid section functionality", async ({ page }) => {
|
|
||||||
// Check section header
|
|
||||||
await expect(
|
|
||||||
page.locator('h1:has-text("We\'ve got your back")'),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator(
|
|
||||||
"text=Use our toolkit to improve, document, and evolve your organization",
|
|
||||||
),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check all four feature cards - use more specific selectors to avoid conflicts
|
|
||||||
const featureGrid = page.locator('[role="grid"]');
|
|
||||||
await expect(featureGrid.locator("text=Decision-making")).toBeVisible();
|
|
||||||
await expect(featureGrid.locator("text=Values alignment")).toBeVisible();
|
|
||||||
await expect(featureGrid.locator("text=Membership")).toBeVisible();
|
|
||||||
await expect(featureGrid.locator("text=Conflict resolution")).toBeVisible();
|
|
||||||
|
|
||||||
// Check feature links - be more specific to only get the feature grid links
|
|
||||||
const featureLinks = featureGrid.locator('a[href^="#"]');
|
|
||||||
await expect(featureLinks).toHaveCount(4);
|
|
||||||
|
|
||||||
// Test feature card interactions
|
|
||||||
await page.locator('a[href="#decision-making"]').click();
|
|
||||||
// Should navigate to decision-making section
|
|
||||||
});
|
|
||||||
|
|
||||||
test("quote block section displays correctly", async ({ page }) => {
|
|
||||||
// Check quote content
|
|
||||||
await expect(
|
|
||||||
page.locator("text=The rules of decision-making must be open"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check author and source
|
|
||||||
await expect(page.locator("text=Jo Freeman")).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=The Tyranny of Structurelessness"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check avatar
|
|
||||||
await expect(
|
|
||||||
page.locator('img[alt="Portrait of Jo Freeman"]'),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check decorative elements
|
|
||||||
await expect(
|
|
||||||
page.locator('[class*="pointer-events-none absolute z-0"]').first(),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ask organizer section functionality", async ({ page }) => {
|
|
||||||
// Check section content
|
|
||||||
await expect(page.locator("text=Still have questions?")).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Get answers from an experienced organizer"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Check CTA button (it's actually a link)
|
|
||||||
const askLinks = page.locator('a:has-text("Ask an organizer")');
|
|
||||||
const askLinkCount = await askLinks.count();
|
|
||||||
let visibleAskLink = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < askLinkCount; i++) {
|
|
||||||
const link = askLinks.nth(i);
|
|
||||||
if (await link.isVisible()) {
|
|
||||||
visibleAskLink = link;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleAskLink) {
|
|
||||||
throw new Error('No visible "Ask an organizer" link found');
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(visibleAskLink).toBeEnabled();
|
|
||||||
|
|
||||||
// Test link interaction
|
|
||||||
await visibleAskLink.click();
|
|
||||||
// Should trigger analytics tracking
|
|
||||||
});
|
|
||||||
|
|
||||||
test("header navigation functionality", async ({ page }) => {
|
|
||||||
// Check header is present
|
|
||||||
await expect(page.locator("header")).toBeVisible();
|
|
||||||
|
|
||||||
// Check navigation elements
|
|
||||||
await expect(page.locator("nav").first()).toBeVisible();
|
|
||||||
|
|
||||||
// Test logo click specifically (not the entire header)
|
|
||||||
// The logo has different visibility classes for different breakpoints
|
|
||||||
// Find any visible logo link
|
|
||||||
const logoLinks = page.locator('a[aria-label="CommunityRule Logo"]');
|
|
||||||
const logoCount = await logoLinks.count();
|
|
||||||
expect(logoCount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Find the first visible logo link
|
|
||||||
let visibleLogo = null;
|
|
||||||
for (let i = 0; i < logoCount; i++) {
|
|
||||||
const logo = logoLinks.nth(i);
|
|
||||||
if (await logo.isVisible()) {
|
|
||||||
visibleLogo = logo;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(visibleLogo).not.toBeNull();
|
|
||||||
await visibleLogo.click();
|
|
||||||
// Should navigate to homepage
|
|
||||||
await expect(page).toHaveURL(/\/#?$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("footer section displays correctly", async ({ page }) => {
|
|
||||||
// Scroll to footer
|
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
||||||
|
|
||||||
// Check footer is present - use the main page footer, not the quote footer
|
|
||||||
const mainFooter = page.locator("footer").last();
|
|
||||||
await expect(mainFooter).toBeVisible();
|
|
||||||
|
|
||||||
// Check footer content
|
|
||||||
await expect(mainFooter).toContainText("CommunityRule");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("responsive design behavior", async ({ page }) => {
|
|
||||||
// Test mobile viewport
|
|
||||||
await page.setViewportSize({ width: 375, height: 667 });
|
|
||||||
await expect(
|
|
||||||
page.locator("h1, h2").filter({ hasText: "Collaborate" }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Test tablet viewport
|
|
||||||
await page.setViewportSize({ width: 768, height: 1024 });
|
|
||||||
await expect(
|
|
||||||
page.locator("h1, h2").filter({ hasText: "Collaborate" }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Test desktop viewport
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
|
||||||
await expect(
|
|
||||||
page.locator("h1, h2").filter({ hasText: "Collaborate" }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keyboard navigation and accessibility", async ({ page }) => {
|
|
||||||
// Test tab navigation
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await expect(page.locator(":focus")).toBeVisible();
|
|
||||||
|
|
||||||
// Navigate through interactive elements
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
|
|
||||||
// Test Enter key on buttons
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
|
|
||||||
// Test Escape key
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("page performance metrics", async ({ page }) => {
|
|
||||||
// Measure page load time
|
|
||||||
const startTime = Date.now();
|
|
||||||
await page.goto("/");
|
|
||||||
const loadTime = Date.now() - startTime;
|
|
||||||
|
|
||||||
// Page should load within reasonable time (5 seconds)
|
|
||||||
expect(loadTime).toBeLessThan(5000);
|
|
||||||
|
|
||||||
// Check for any console errors
|
|
||||||
const consoleErrors: string[] = [];
|
|
||||||
page.on("console", (msg) => {
|
|
||||||
if (msg.type() === "error") {
|
|
||||||
consoleErrors.push(msg.text());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
expect(consoleErrors.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accessibility standards compliance", async ({ page }) => {
|
|
||||||
// Basic accessibility checks
|
|
||||||
const html = page.locator("html");
|
|
||||||
const lang = await html.getAttribute("lang");
|
|
||||||
expect(lang).toBeTruthy();
|
|
||||||
|
|
||||||
// Check for main heading
|
|
||||||
const h1 = page.locator("h1").first();
|
|
||||||
await expect(h1).toBeVisible();
|
|
||||||
|
|
||||||
// Check for main landmark
|
|
||||||
const main = page.locator("main, [role='main']");
|
|
||||||
await expect(main).toBeVisible();
|
|
||||||
|
|
||||||
// Check for navigation
|
|
||||||
const nav = page.locator("nav, [role='navigation']").first();
|
|
||||||
await expect(nav).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("scroll behavior and smooth scrolling", async ({ page }) => {
|
|
||||||
// Test smooth scrolling to sections
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
throw new Error(
|
|
||||||
'No visible "Learn how CommunityRule works" button found',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
|
|
||||||
// Should smoothly scroll to numbered cards section
|
|
||||||
await page.waitForTimeout(1000); // Wait for scroll animation
|
|
||||||
|
|
||||||
// Check we're at the numbered cards section
|
|
||||||
await expect(
|
|
||||||
page.locator('h2:has-text("How CommunityRule works")'),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("image loading and optimization", async ({ page }) => {
|
|
||||||
// Check all images load properly
|
|
||||||
const images = page.locator("img");
|
|
||||||
const imageCount = await images.count();
|
|
||||||
expect(imageCount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Wait for page to be stable, but don't wait indefinitely for images
|
|
||||||
await page.waitForLoadState("domcontentloaded");
|
|
||||||
await page.waitForTimeout(2000); // Give images time to load
|
|
||||||
|
|
||||||
// Check for any broken images, but be more lenient
|
|
||||||
const brokenImages = await page.evaluate(() => {
|
|
||||||
const imgs = document.querySelectorAll("img");
|
|
||||||
return Array.from(imgs).filter(
|
|
||||||
(img) => !img.complete || img.naturalWidth === 0,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Allow some images to be loading (not necessarily broken)
|
|
||||||
// Only fail if more than 50% of images are broken
|
|
||||||
const brokenRatio = brokenImages.length / imageCount;
|
|
||||||
expect(brokenRatio).toBeLessThan(0.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("form interactions and validation", async ({ page }) => {
|
|
||||||
// Test any form elements (if present)
|
|
||||||
const forms = page.locator("form");
|
|
||||||
const formCount = await forms.count();
|
|
||||||
|
|
||||||
if (formCount > 0) {
|
|
||||||
// Test form submission
|
|
||||||
const submitButton = page.locator('button[type="submit"]');
|
|
||||||
if ((await submitButton.count()) > 0) {
|
|
||||||
await submitButton.click();
|
|
||||||
// Should handle form submission appropriately
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("error handling and fallbacks", async ({ page }) => {
|
|
||||||
// Test with slow network
|
|
||||||
await page.route("**/*", (route) => {
|
|
||||||
route.continue();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test with offline mode (page.setOffline() not available in current Playwright)
|
|
||||||
// Instead, test that the page loads and functions normally
|
|
||||||
await expect(page.locator("body")).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,89 +1,66 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { PlaywrightPerformanceMonitor } from "../performance/performance-monitor.js";
|
|
||||||
|
|
||||||
// Environment-aware performance budgets and thresholds
|
// Performance budgets - simplified for E2E tests
|
||||||
// Adjusted for development environment
|
// Comprehensive performance testing is handled by Lighthouse CI
|
||||||
const PERFORMANCE_BUDGETS = {
|
const PERFORMANCE_BUDGETS = {
|
||||||
// Page load performance
|
page_load_time: 4000, // 4 seconds
|
||||||
page_load_time: 4000, // 4 seconds - increased for dev environment
|
first_contentful_paint: 2500, // 2.5 seconds
|
||||||
first_contentful_paint: 2500, // 2.5 seconds - increased for dev environment
|
largest_contentful_paint: 3000, // 3 seconds
|
||||||
largest_contentful_paint: 3000, // 3 seconds - increased for dev environment
|
first_input_delay: 150, // 150ms
|
||||||
first_input_delay: 150, // 150ms - increased for dev environment
|
ttfb: 1500, // 1.5 seconds
|
||||||
|
dom_content_loaded: 2000, // 2 seconds
|
||||||
// Navigation timing
|
full_load: 4000, // 4 seconds
|
||||||
dns_lookup: 100, // 100ms
|
|
||||||
tcp_connection: 200, // 200ms
|
|
||||||
ttfb: 1500, // 1500ms - increased to be more realistic for development environment and mobile
|
|
||||||
dom_content_loaded: 2000, // 2 seconds - increased for dev environment
|
|
||||||
full_load: 4000, // 4 seconds - increased for dev environment
|
|
||||||
|
|
||||||
// Component performance
|
|
||||||
component_render_time: 700, // 700ms - increased for dev environment
|
|
||||||
interaction_time: 1000, // 1000ms - increased for development environment and mobile
|
|
||||||
scroll_performance: process.env.CI ? 250 : 150, // More realistic for dev and mobile (150ms vs 100ms)
|
|
||||||
|
|
||||||
// Resource performance
|
|
||||||
network_request_duration: 1500, // 1.5 seconds - increased for dev environment
|
|
||||||
memory_usage_mb: 60, // 60MB - increased for dev environment
|
|
||||||
};
|
|
||||||
|
|
||||||
// Baseline metrics for regression detection
|
|
||||||
// Adjusted for development environment (more realistic baselines)
|
|
||||||
const BASELINE_METRICS = {
|
|
||||||
page_load_time: 2500, // Increased from 2000ms
|
|
||||||
first_contentful_paint: 1800, // Increased from 1500ms
|
|
||||||
largest_contentful_paint: 2200, // Increased from 2000ms
|
|
||||||
first_input_delay: 80, // Increased from 50ms
|
|
||||||
dns_lookup: 50,
|
|
||||||
tcp_connection: 100,
|
|
||||||
ttfb: 600, // Increased from 400ms to be more realistic for dev
|
|
||||||
dom_content_loaded: 1200, // Increased from 1000ms
|
|
||||||
full_load: 2500, // Increased from 2000ms
|
|
||||||
component_render_time: 400, // Increased from 300ms
|
|
||||||
interaction_time: 200, // Increased from 100ms to be more realistic for mobile
|
|
||||||
scroll_performance: 100, // Increased from 60ms to be more realistic for mobile
|
|
||||||
network_request_duration: 700, // Increased from 500ms
|
|
||||||
memory_usage_mb: 40, // Increased from 30MB
|
|
||||||
};
|
};
|
||||||
|
|
||||||
test.describe("Performance Monitoring", () => {
|
test.describe("Performance Monitoring", () => {
|
||||||
let performanceMonitor: PlaywrightPerformanceMonitor;
|
test.beforeEach(async () => {
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
// Mark tests as slower in CI environment
|
|
||||||
if (process.env.CI) test.slow();
|
if (process.env.CI) test.slow();
|
||||||
|
|
||||||
performanceMonitor = new PlaywrightPerformanceMonitor(page);
|
|
||||||
performanceMonitor.setThresholds(PERFORMANCE_BUDGETS);
|
|
||||||
performanceMonitor.setBaselines(BASELINE_METRICS);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("homepage load performance", async ({ page: _page }) => {
|
test("homepage load performance", async ({ page }) => {
|
||||||
const result = await performanceMonitor.measurePageLoad("/");
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Navigate to homepage
|
||||||
|
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
||||||
|
|
||||||
|
const loadTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Get performance metrics from browser
|
||||||
|
const metrics = await page.evaluate(() => {
|
||||||
|
const navigation = performance.getEntriesByType("navigation")[0];
|
||||||
|
const paint = performance.getEntriesByType("paint");
|
||||||
|
|
||||||
|
return {
|
||||||
|
ttfb: navigation?.responseStart - navigation?.requestStart || 0,
|
||||||
|
domContentLoaded:
|
||||||
|
navigation?.domContentLoadedEventEnd -
|
||||||
|
navigation?.domContentLoadedEventStart || 0,
|
||||||
|
load: navigation?.loadEventEnd - navigation?.loadEventStart || 0,
|
||||||
|
firstContentfulPaint:
|
||||||
|
paint.find((p) => p.name === "first-contentful-paint")?.startTime ||
|
||||||
|
0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Assert page load time is within budget
|
// Assert page load time is within budget
|
||||||
expect(result.loadTime).toBeLessThan(PERFORMANCE_BUDGETS.page_load_time);
|
expect(loadTime).toBeLessThan(PERFORMANCE_BUDGETS.page_load_time);
|
||||||
|
|
||||||
// Assert individual metrics
|
// Assert individual metrics
|
||||||
expect(result.metrics.ttfb).toBeLessThan(PERFORMANCE_BUDGETS.ttfb);
|
expect(metrics.ttfb).toBeLessThan(PERFORMANCE_BUDGETS.ttfb);
|
||||||
expect(result.metrics.domContentLoaded).toBeLessThan(
|
expect(metrics.domContentLoaded).toBeLessThan(
|
||||||
PERFORMANCE_BUDGETS.dom_content_loaded,
|
PERFORMANCE_BUDGETS.dom_content_loaded,
|
||||||
);
|
);
|
||||||
expect(result.metrics.load).toBeLessThan(PERFORMANCE_BUDGETS.full_load);
|
expect(metrics.load).toBeLessThan(PERFORMANCE_BUDGETS.full_load);
|
||||||
|
expect(metrics.firstContentfulPaint).toBeLessThan(
|
||||||
// Check for performance regressions
|
PERFORMANCE_BUDGETS.first_contentful_paint,
|
||||||
const summary = performanceMonitor.getSummary();
|
);
|
||||||
console.log("Performance Summary:", summary);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("core web vitals", async ({ page }) => {
|
test("core web vitals", async ({ page }) => {
|
||||||
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
||||||
|
|
||||||
// Wait for page to fully load
|
|
||||||
// Use "load" state instead of "networkidle" to handle dynamically imported components
|
|
||||||
await page.waitForLoadState("load");
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
// Get Core Web Vitals with timeout
|
// Get Core Web Vitals using browser Performance API
|
||||||
const coreWebVitals = (await page.evaluate(() => {
|
const coreWebVitals = (await page.evaluate(() => {
|
||||||
return new Promise<{ lcp: number; fid: number; cls: number }>(
|
return new Promise<{ lcp: number; fid: number; cls: number }>(
|
||||||
(resolve) => {
|
(resolve) => {
|
||||||
@@ -145,298 +122,4 @@ test.describe("Performance Monitoring", () => {
|
|||||||
);
|
);
|
||||||
expect(coreWebVitals.cls).toBeLessThan(0.1); // CLS should be less than 0.1
|
expect(coreWebVitals.cls).toBeLessThan(0.1); // CLS should be less than 0.1
|
||||||
});
|
});
|
||||||
|
|
||||||
test("component render performance", async ({ page }) => {
|
|
||||||
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
|
||||||
|
|
||||||
// Measure header render time
|
|
||||||
const headerRenderTime =
|
|
||||||
await performanceMonitor.measureComponentRender("header");
|
|
||||||
expect(headerRenderTime).toBeLessThan(
|
|
||||||
PERFORMANCE_BUDGETS.component_render_time,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Measure footer render time
|
|
||||||
const footerRenderTime =
|
|
||||||
await performanceMonitor.measureComponentRender("footer");
|
|
||||||
expect(footerRenderTime).toBeLessThan(
|
|
||||||
PERFORMANCE_BUDGETS.component_render_time,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Measure main content render time
|
|
||||||
const mainRenderTime =
|
|
||||||
await performanceMonitor.measureComponentRender("main");
|
|
||||||
expect(mainRenderTime).toBeLessThan(
|
|
||||||
PERFORMANCE_BUDGETS.component_render_time,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("interaction performance", async ({ page }) => {
|
|
||||||
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
|
||||||
|
|
||||||
// Wait for page to be ready
|
|
||||||
await page.waitForLoadState("domcontentloaded");
|
|
||||||
await page.waitForTimeout(1000); // Give page time to stabilize
|
|
||||||
|
|
||||||
// Measure button click performance with better element selection
|
|
||||||
const buttonClickTime = await performanceMonitor.measureInteraction(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
async () => {
|
|
||||||
const learnButtons = page.locator(
|
|
||||||
'button:has-text("Learn how CommunityRule works")',
|
|
||||||
);
|
|
||||||
const buttonCount = await learnButtons.count();
|
|
||||||
let visibleButton = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < buttonCount; i++) {
|
|
||||||
const button = learnButtons.nth(i);
|
|
||||||
if (await button.isVisible()) {
|
|
||||||
visibleButton = button;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleButton) {
|
|
||||||
// Skip this test if button is not visible (might be hidden on some viewports)
|
|
||||||
console.log("Button not visible, skipping button click test");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleButton.click();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (buttonClickTime !== null) {
|
|
||||||
expect(buttonClickTime).toBeLessThan(
|
|
||||||
PERFORMANCE_BUDGETS.interaction_time,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measure link click performance with better element selection
|
|
||||||
const linkClickTime = await performanceMonitor.measureInteraction(
|
|
||||||
'a:has-text("Use cases")',
|
|
||||||
async () => {
|
|
||||||
const useCaseLinks = page.locator('a:has-text("Use cases")');
|
|
||||||
const linkCount = await useCaseLinks.count();
|
|
||||||
let visibleLink = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < linkCount; i++) {
|
|
||||||
const link = useCaseLinks.nth(i);
|
|
||||||
if (await link.isVisible()) {
|
|
||||||
visibleLink = link;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visibleLink) {
|
|
||||||
// Skip this test if link is not visible
|
|
||||||
console.log("Link not visible, skipping link click test");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await visibleLink.click();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (linkClickTime !== null) {
|
|
||||||
expect(linkClickTime).toBeLessThan(PERFORMANCE_BUDGETS.interaction_time);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("scroll performance", async ({ page }) => {
|
|
||||||
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
|
||||||
|
|
||||||
// Measure scroll performance
|
|
||||||
const scrollTime = await performanceMonitor.measureScrollPerformance();
|
|
||||||
expect(scrollTime).toBeLessThan(PERFORMANCE_BUDGETS.scroll_performance);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("memory usage", async ({ page }) => {
|
|
||||||
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
|
||||||
|
|
||||||
// Get memory usage
|
|
||||||
const memoryUsage = await performanceMonitor.getMemoryUsage();
|
|
||||||
|
|
||||||
if (memoryUsage) {
|
|
||||||
const usedMemoryMB = memoryUsage.usedJSHeapSize / 1024 / 1024;
|
|
||||||
expect(usedMemoryMB).toBeLessThan(PERFORMANCE_BUDGETS.memory_usage_mb);
|
|
||||||
|
|
||||||
console.log(`Memory Usage: ${usedMemoryMB.toFixed(2)}MB`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("network request performance", async ({ page }) => {
|
|
||||||
await performanceMonitor.monitorNetworkRequests();
|
|
||||||
|
|
||||||
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
|
||||||
// Wait for load state instead of networkidle to handle dynamic imports
|
|
||||||
await page.waitForLoadState("load");
|
|
||||||
|
|
||||||
// Check that all requests completed within budget
|
|
||||||
const summary = performanceMonitor.getSummary();
|
|
||||||
if (summary.network_request_duration) {
|
|
||||||
expect(summary.network_request_duration.average).toBeLessThan(
|
|
||||||
PERFORMANCE_BUDGETS.network_request_duration,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("responsive performance across breakpoints", async ({ page }) => {
|
|
||||||
const breakpoints = [
|
|
||||||
{ name: "mobile", width: 375, height: 667 },
|
|
||||||
{ name: "tablet", width: 768, height: 1024 },
|
|
||||||
{ name: "desktop", width: 1280, height: 720 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const breakpoint of breakpoints) {
|
|
||||||
await page.setViewportSize(breakpoint);
|
|
||||||
|
|
||||||
const result = await performanceMonitor.measurePageLoad("/");
|
|
||||||
|
|
||||||
// Assert performance is maintained across breakpoints
|
|
||||||
expect(result.loadTime).toBeLessThan(PERFORMANCE_BUDGETS.page_load_time);
|
|
||||||
|
|
||||||
console.log(`${breakpoint.name} load time: ${result.loadTime}ms`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("performance under load", async ({ page }) => {
|
|
||||||
// Simulate slower network conditions
|
|
||||||
await page.route("**/*", (route) => {
|
|
||||||
route.continue();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add artificial delay to simulate network latency
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
const originalFetch = window.fetch;
|
|
||||||
window.fetch = async (...args) => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms delay
|
|
||||||
return originalFetch(...args);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await performanceMonitor.measurePageLoad("/");
|
|
||||||
|
|
||||||
// Even under load, page should load within reasonable time
|
|
||||||
expect(result.loadTime).toBeLessThan(
|
|
||||||
PERFORMANCE_BUDGETS.page_load_time * 1.5,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("performance regression detection", async ({ page }) => {
|
|
||||||
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
|
||||||
|
|
||||||
// Simulate a performance regression by adding a heavy operation
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
// Add a heavy operation that would cause regression
|
|
||||||
const heavyOperation = () => {
|
|
||||||
let result = 0;
|
|
||||||
for (let i = 0; i < 1000000; i++) {
|
|
||||||
result += Math.random();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Execute heavy operation on page load
|
|
||||||
window.addEventListener("load", () => {
|
|
||||||
heavyOperation();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await performanceMonitor.measurePageLoad("/");
|
|
||||||
|
|
||||||
// This should trigger a performance regression warning
|
|
||||||
const summary = performanceMonitor.getSummary();
|
|
||||||
console.log("Performance Summary with Regression:", summary);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("performance metrics export", async ({ page }) => {
|
|
||||||
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
|
||||||
|
|
||||||
// Perform various operations to collect metrics
|
|
||||||
await performanceMonitor.measureComponentRender("header");
|
|
||||||
await performanceMonitor.measureScrollPerformance();
|
|
||||||
await performanceMonitor.getMemoryUsage();
|
|
||||||
|
|
||||||
// Export all metrics
|
|
||||||
const exportedData = performanceMonitor.export();
|
|
||||||
|
|
||||||
// Verify exported data structure
|
|
||||||
expect(exportedData.metrics).toBeDefined();
|
|
||||||
expect(exportedData.baselines).toBeDefined();
|
|
||||||
expect(exportedData.thresholds).toBeDefined();
|
|
||||||
expect(exportedData.summary).toBeDefined();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Exported Performance Data:",
|
|
||||||
JSON.stringify(exportedData, null, 2),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("performance budget compliance", async ({ page }) => {
|
|
||||||
await page.goto("/", { waitUntil: "load", timeout: 60000 });
|
|
||||||
|
|
||||||
// Collect comprehensive metrics
|
|
||||||
await performanceMonitor.measurePageLoad("/");
|
|
||||||
await performanceMonitor.measureComponentRender("header");
|
|
||||||
await performanceMonitor.measureComponentRender("footer");
|
|
||||||
await performanceMonitor.measureScrollPerformance();
|
|
||||||
await performanceMonitor.getMemoryUsage();
|
|
||||||
|
|
||||||
const summary = performanceMonitor.getSummary();
|
|
||||||
|
|
||||||
// Check all metrics against budgets
|
|
||||||
for (const [metricName, budget] of Object.entries(PERFORMANCE_BUDGETS)) {
|
|
||||||
if (summary[metricName]) {
|
|
||||||
const actualValue =
|
|
||||||
summary[metricName].latest || summary[metricName].average;
|
|
||||||
expect(actualValue).toBeLessThan(budget);
|
|
||||||
console.log(`${metricName}: ${actualValue}ms (budget: ${budget}ms)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Performance Regression Testing", () => {
|
|
||||||
test("detect performance regressions over time", async ({ page }) => {
|
|
||||||
const performanceMonitor = new PlaywrightPerformanceMonitor(page);
|
|
||||||
|
|
||||||
// Set strict baselines for regression detection
|
|
||||||
const strictBaselines = {
|
|
||||||
page_load_time: 1500,
|
|
||||||
first_contentful_paint: 1000,
|
|
||||||
component_render_time: 200,
|
|
||||||
interaction_time: 30,
|
|
||||||
};
|
|
||||||
|
|
||||||
performanceMonitor.setBaselines(strictBaselines);
|
|
||||||
|
|
||||||
// Run multiple iterations to detect trends
|
|
||||||
const iterations = 3;
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < iterations; i++) {
|
|
||||||
// measurePageLoad already handles timeouts and wait conditions
|
|
||||||
const result = await performanceMonitor.measurePageLoad("/");
|
|
||||||
results.push(result.loadTime);
|
|
||||||
|
|
||||||
// Small delay between iterations
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for consistent performance
|
|
||||||
const averageLoadTime = results.reduce((a, b) => a + b, 0) / results.length;
|
|
||||||
const variance =
|
|
||||||
results.reduce(
|
|
||||||
(acc, val) => acc + Math.pow(val - averageLoadTime, 2),
|
|
||||||
0,
|
|
||||||
) / results.length;
|
|
||||||
|
|
||||||
// Performance should be consistent (low variance)
|
|
||||||
// Increased threshold for development environment which has more variability
|
|
||||||
expect(variance).toBeLessThan(600000); // Variance should be less than 600ms² for dev environment
|
|
||||||
|
|
||||||
console.log(`Average load time: ${averageLoadTime}ms`);
|
|
||||||
console.log(`Variance: ${variance}`);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,396 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("User Journeys", () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("complete user journey: learn about CommunityRule", async ({ page }) => {
|
|
||||||
// 1. User lands on homepage
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
|
|
||||||
// 2. User reads hero section
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Help your community make important decisions"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// 3. User clicks CTA to learn more
|
|
||||||
const learnButton = page
|
|
||||||
.locator('button:has-text("Learn how CommunityRule works")')
|
|
||||||
.first();
|
|
||||||
if ((await learnButton.count()) > 0 && (await learnButton.isVisible())) {
|
|
||||||
await learnButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. User scrolls to numbered cards section
|
|
||||||
await expect(
|
|
||||||
page.locator('h2:has-text("How CommunityRule works")'),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// 5. User reads the process steps
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Document how your community makes decisions"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Build an operating manual for a successful community"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator(
|
|
||||||
"text=Get a link to your manual for your group to review and evolve",
|
|
||||||
),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// 6. User explores rule templates
|
|
||||||
await page.locator("text=Consensus clusters").first().click();
|
|
||||||
await page.locator("text=Consensus").nth(1).click(); // Use nth(1) to get the second "Consensus" element
|
|
||||||
await page.locator("text=Elected Board").first().click();
|
|
||||||
await page.locator("text=Petition").first().click();
|
|
||||||
|
|
||||||
// 7. User checks out features - check if elements exist and are visible first
|
|
||||||
const features = [
|
|
||||||
"Decision-making support",
|
|
||||||
"Values alignment exercises",
|
|
||||||
"Membership guidance",
|
|
||||||
"Conflict resolution tools",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const feature of features) {
|
|
||||||
const featureElement = page.locator(`text=${feature}`);
|
|
||||||
if (
|
|
||||||
(await featureElement.count()) > 0 &&
|
|
||||||
(await featureElement.first().isVisible())
|
|
||||||
) {
|
|
||||||
await featureElement.first().click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. User reads testimonial
|
|
||||||
await expect(page.locator("text=Jo Freeman")).toBeVisible();
|
|
||||||
|
|
||||||
// 9. User decides to contact organizer
|
|
||||||
const askButton = page.locator('button:has-text("Ask an organizer")');
|
|
||||||
if (
|
|
||||||
(await askButton.count()) > 0 &&
|
|
||||||
(await askButton.first().isVisible())
|
|
||||||
) {
|
|
||||||
await askButton.first().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. User creates CommunityRule
|
|
||||||
const createButton = page.locator(
|
|
||||||
'button:has-text("Create CommunityRule")',
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
(await createButton.count()) > 0 &&
|
|
||||||
(await createButton.first().isVisible())
|
|
||||||
) {
|
|
||||||
await createButton.first().click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: explore rule templates", async ({ page }) => {
|
|
||||||
// Scroll to rule stack section
|
|
||||||
await page.locator("text=Consensus clusters").scrollIntoViewIfNeeded();
|
|
||||||
|
|
||||||
// Explore each rule template
|
|
||||||
const ruleTemplates = [
|
|
||||||
"Consensus clusters",
|
|
||||||
"Consensus",
|
|
||||||
"Elected Board",
|
|
||||||
"Petition",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const template of ruleTemplates) {
|
|
||||||
const templateElement = page.locator(`text=${template}`);
|
|
||||||
if (template === "Consensus") {
|
|
||||||
await templateElement.nth(1).click(); // Use nth(1) for the second "Consensus" element
|
|
||||||
} else {
|
|
||||||
await templateElement.first().click();
|
|
||||||
}
|
|
||||||
// Should trigger analytics tracking
|
|
||||||
await page.waitForTimeout(500); // Brief pause between clicks
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click "See all templates"
|
|
||||||
await page.locator('button:has-text("See all templates")').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: explore feature tools", async ({ page }) => {
|
|
||||||
// Scroll to feature grid section
|
|
||||||
await page.locator("text=We've got your back").scrollIntoViewIfNeeded();
|
|
||||||
|
|
||||||
// Explore each feature
|
|
||||||
const features = [
|
|
||||||
{ name: "Decision-making support", href: "#decision-making" },
|
|
||||||
{ name: "Values alignment exercises", href: "#values-alignment" },
|
|
||||||
{ name: "Membership guidance", href: "#membership-guidance" },
|
|
||||||
{ name: "Conflict resolution tools", href: "#conflict-resolution" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const feature of features) {
|
|
||||||
await page.locator(`a[href="${feature.href}"]`).click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: contact organizer", async ({ page }) => {
|
|
||||||
// Scroll to ask organizer section
|
|
||||||
await page.locator("text=Still have questions?").scrollIntoViewIfNeeded();
|
|
||||||
|
|
||||||
// Read the section
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Get answers from an experienced organizer"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Click contact button - check if it exists and is visible first
|
|
||||||
const askButton = page.locator('button:has-text("Ask an organizer")');
|
|
||||||
if (
|
|
||||||
(await askButton.count()) > 0 &&
|
|
||||||
(await askButton.first().isVisible())
|
|
||||||
) {
|
|
||||||
await askButton.first().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should trigger analytics tracking
|
|
||||||
// In a real app, this might open a contact form or modal
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: create CommunityRule", async ({ page }) => {
|
|
||||||
// Simplified approach - just check if the button exists and is visible
|
|
||||||
const createButton = page.locator(
|
|
||||||
'button:has-text("Create CommunityRule")',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
(await createButton.count()) > 0 &&
|
|
||||||
(await createButton.first().isVisible())
|
|
||||||
) {
|
|
||||||
await createButton.first().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should navigate to creation flow
|
|
||||||
// In a real app, this would go to a form or wizard
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: learn how it works", async ({ page }) => {
|
|
||||||
// This test simulates a user learning about how CommunityRule works
|
|
||||||
// Since the CTA button doesn't actually navigate anywhere (href="#"),
|
|
||||||
// we'll focus on the actual user journey: reading about the process
|
|
||||||
|
|
||||||
// Wait for page to load
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
|
|
||||||
// User starts by reading the hero section
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Help your community make important decisions"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// User scrolls down to learn about how CommunityRule works
|
|
||||||
await page
|
|
||||||
.locator('h2:has-text("How CommunityRule works")')
|
|
||||||
.scrollIntoViewIfNeeded();
|
|
||||||
await expect(
|
|
||||||
page.locator('h2:has-text("How CommunityRule works")'),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// User reads the process steps
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Document how your community makes decisions"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator("text=Build an operating manual for a successful community"),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.locator(
|
|
||||||
"text=Get a link to your manual for your group to review and evolve",
|
|
||||||
),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// User explores rule templates
|
|
||||||
await page.locator("text=Consensus clusters").first().click();
|
|
||||||
await page.locator("text=Consensus").nth(1).click();
|
|
||||||
await page.locator("text=Elected Board").first().click();
|
|
||||||
await page.locator("text=Petition").first().click();
|
|
||||||
|
|
||||||
// User has successfully learned about how CommunityRule works
|
|
||||||
await expect(
|
|
||||||
page.locator("text=We've got your back, every step of the way"),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: scroll through entire page", async ({ page }) => {
|
|
||||||
// Start at top
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
|
|
||||||
// Simplified approach - just scroll to bottom and check footer
|
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
||||||
await expect(page.locator("footer").first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: keyboard navigation through page", async ({ page }) => {
|
|
||||||
// Start with tab navigation
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await expect(page.locator(":focus")).toBeVisible();
|
|
||||||
|
|
||||||
// Navigate through all interactive elements
|
|
||||||
let tabCount = 0;
|
|
||||||
const maxTabs = 50; // Prevent infinite loop
|
|
||||||
|
|
||||||
while (tabCount < maxTabs) {
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
tabCount++;
|
|
||||||
|
|
||||||
// Check if we've cycled back to the beginning
|
|
||||||
const focusedElement = page.locator(":focus");
|
|
||||||
if ((await focusedElement.count()) === 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Enter key on focused elements
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: mobile navigation", async ({ page }) => {
|
|
||||||
// Set mobile viewport
|
|
||||||
await page.setViewportSize({ width: 375, height: 667 });
|
|
||||||
|
|
||||||
// Navigate through page on mobile
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
|
|
||||||
// Scroll through sections
|
|
||||||
await page.locator("section").first().scrollIntoViewIfNeeded();
|
|
||||||
|
|
||||||
// Test basic touch interactions - check if elements exist and are visible first
|
|
||||||
const learnButton = page
|
|
||||||
.locator('button:has-text("Learn how CommunityRule works")')
|
|
||||||
.first();
|
|
||||||
if ((await learnButton.count()) > 0 && (await learnButton.isVisible())) {
|
|
||||||
await learnButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const consensusText = page.locator("text=Consensus clusters");
|
|
||||||
if (
|
|
||||||
(await consensusText.count()) > 0 &&
|
|
||||||
(await consensusText.isVisible())
|
|
||||||
) {
|
|
||||||
await consensusText.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const askButton = page
|
|
||||||
.locator('button:has-text("Ask an organizer")')
|
|
||||||
.first();
|
|
||||||
if ((await askButton.count()) > 0 && (await askButton.isVisible())) {
|
|
||||||
await askButton.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: tablet navigation", async ({ page }) => {
|
|
||||||
// Set tablet viewport
|
|
||||||
await page.setViewportSize({ width: 768, height: 1024 });
|
|
||||||
|
|
||||||
// Navigate through page on tablet
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
|
|
||||||
// Test tablet-specific interactions - check if elements exist and are visible first
|
|
||||||
const learnButton = page
|
|
||||||
.locator('button:has-text("Learn how CommunityRule works")')
|
|
||||||
.first();
|
|
||||||
if ((await learnButton.count()) > 0 && (await learnButton.isVisible())) {
|
|
||||||
await learnButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const consensusText = page.locator("text=Consensus clusters");
|
|
||||||
if (
|
|
||||||
(await consensusText.count()) > 0 &&
|
|
||||||
(await consensusText.isVisible())
|
|
||||||
) {
|
|
||||||
await consensusText.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const askButton = page
|
|
||||||
.locator('button:has-text("Ask an organizer")')
|
|
||||||
.first();
|
|
||||||
if ((await askButton.count()) > 0 && (await askButton.isVisible())) {
|
|
||||||
await askButton.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: desktop navigation", async ({ page }) => {
|
|
||||||
// Set desktop viewport
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
|
||||||
|
|
||||||
// Navigate through page on desktop
|
|
||||||
await expect(page.locator("text=Collaborate")).toBeVisible();
|
|
||||||
|
|
||||||
// Test desktop-specific interactions - check if elements exist and are visible first
|
|
||||||
const learnButton = page
|
|
||||||
.locator('button:has-text("Learn how CommunityRule works")')
|
|
||||||
.first();
|
|
||||||
if ((await learnButton.count()) > 0 && (await learnButton.isVisible())) {
|
|
||||||
await learnButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const consensusText = page.locator("text=Consensus clusters");
|
|
||||||
if (
|
|
||||||
(await consensusText.count()) > 0 &&
|
|
||||||
(await consensusText.isVisible())
|
|
||||||
) {
|
|
||||||
await consensusText.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const askButton = page
|
|
||||||
.locator('button:has-text("Ask an organizer")')
|
|
||||||
.first();
|
|
||||||
if ((await askButton.count()) > 0 && (await askButton.isVisible())) {
|
|
||||||
await askButton.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: accessibility navigation", async ({ page }) => {
|
|
||||||
// Test screen reader navigation
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
|
|
||||||
// Navigate through landmarks
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
|
|
||||||
// Test heading navigation (if supported)
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
|
|
||||||
// Test form navigation
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
|
|
||||||
// Test button activation
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("user journey: performance testing", async ({ page }) => {
|
|
||||||
// Measure initial page load
|
|
||||||
const startTime = Date.now();
|
|
||||||
await page.goto("/");
|
|
||||||
const loadTime = Date.now() - startTime;
|
|
||||||
|
|
||||||
expect(loadTime).toBeLessThan(3000); // Should load within 3 seconds
|
|
||||||
|
|
||||||
// Measure scroll performance
|
|
||||||
const scrollStartTime = Date.now();
|
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
||||||
const scrollTime = Date.now() - scrollStartTime;
|
|
||||||
|
|
||||||
expect(scrollTime).toBeLessThan(1000); // Should scroll smoothly
|
|
||||||
|
|
||||||
// Measure interaction response time
|
|
||||||
const clickStartTime = Date.now();
|
|
||||||
const learnButton = page
|
|
||||||
.locator('button:has-text("Learn how CommunityRule works")')
|
|
||||||
.first();
|
|
||||||
if ((await learnButton.count()) > 0 && (await learnButton.isVisible())) {
|
|
||||||
await learnButton.click();
|
|
||||||
}
|
|
||||||
const clickTime = Date.now() - clickStartTime;
|
|
||||||
|
|
||||||
expect(clickTime).toBeLessThan(500); // Should respond quickly
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,7 +8,8 @@ test.describe("Visual Regression Tests", () => {
|
|||||||
});
|
});
|
||||||
await page.waitForTimeout(50);
|
await page.waitForTimeout(50);
|
||||||
}
|
}
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
|
test("homepage full page screenshot", async ({ page }) => {
|
||||||
// Add deterministic CSS to normalize rendering
|
// Add deterministic CSS to normalize rendering
|
||||||
await page.addStyleTag({
|
await page.addStyleTag({
|
||||||
content: `
|
content: `
|
||||||
@@ -28,7 +29,6 @@ test.describe("Visual Regression Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
// Wait for all content to load
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Make sure we've really got the webfonts before shots
|
// Make sure we've really got the webfonts before shots
|
||||||
@@ -39,9 +39,7 @@ test.describe("Visual Regression Tests", () => {
|
|||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test("homepage full page screenshot", async ({ page }) => {
|
|
||||||
// Stabilize layout before screenshot
|
// Stabilize layout before screenshot
|
||||||
await settle(page);
|
await settle(page);
|
||||||
|
|
||||||
@@ -54,13 +52,42 @@ test.describe("Visual Regression Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("homepage viewport screenshot", async ({ page }) => {
|
test("homepage viewport screenshot", async ({ page }) => {
|
||||||
|
// Add deterministic CSS to normalize rendering
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: `
|
||||||
|
/* stop caret and selection flicker */
|
||||||
|
* { caret-color: transparent !important; }
|
||||||
|
::selection { background: transparent !important; }
|
||||||
|
/* hide scrollbars */
|
||||||
|
::-webkit-scrollbar { display: none !important; }
|
||||||
|
html { scrollbar-width: none !important; }
|
||||||
|
/* stabilize font rasterization */
|
||||||
|
* {
|
||||||
|
text-rendering: geometricPrecision !important;
|
||||||
|
-webkit-font-smoothing: antialiased !important;
|
||||||
|
-moz-osx-font-smoothing: grayscale !important;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Make sure we've really got the webfonts before shots
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
if (document.fonts && document.fonts.status !== "loaded") {
|
||||||
|
// @ts-ignore
|
||||||
|
await document.fonts.ready;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Stabilize layout before screenshot
|
// Stabilize layout before screenshot
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
// Force layout & a frame boundary
|
|
||||||
void document.body.getBoundingClientRect();
|
void document.body.getBoundingClientRect();
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(50); // give the compositor one tick
|
await page.waitForTimeout(50);
|
||||||
|
|
||||||
// Take viewport screenshot
|
// Take viewport screenshot
|
||||||
await expect(page).toHaveScreenshot("homepage-viewport.png", {
|
await expect(page).toHaveScreenshot("homepage-viewport.png", {
|
||||||
@@ -68,326 +95,30 @@ test.describe("Visual Regression Tests", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hero banner section screenshot", async ({ page }) => {
|
|
||||||
// Scroll to hero section and take screenshot
|
|
||||||
await page.locator("text=Collaborate").scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500); // Wait for animations
|
|
||||||
|
|
||||||
// Stabilize layout before screenshot
|
|
||||||
await page.evaluate(() => {
|
|
||||||
// Force layout & a frame boundary
|
|
||||||
void document.body.getBoundingClientRect();
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(50); // give the compositor one tick
|
|
||||||
|
|
||||||
const heroSection = page.locator("section").first();
|
|
||||||
await expect(heroSection).toHaveScreenshot("hero-banner.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logo wall section screenshot", async ({ page }) => {
|
|
||||||
// Scroll to logo wall section
|
|
||||||
await page
|
|
||||||
.locator("text=Trusted by leading cooperators")
|
|
||||||
.scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const logoSection = page.locator("section").nth(1);
|
|
||||||
await expect(logoSection).toHaveScreenshot("logo-wall.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("numbered cards section screenshot", async ({ page }) => {
|
|
||||||
// Scroll to numbered cards section
|
|
||||||
await page
|
|
||||||
.locator('h2:has-text("How CommunityRule works")')
|
|
||||||
.scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const cardsSection = page.locator("section").nth(2);
|
|
||||||
await expect(cardsSection).toHaveScreenshot("numbered-cards.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rule stack section screenshot", async ({ page }) => {
|
|
||||||
// Scroll to rule stack section
|
|
||||||
await page.locator("text=Consensus clusters").scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const ruleSection = page.locator("section").nth(3);
|
|
||||||
await expect(ruleSection).toHaveScreenshot("rule-stack.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("feature grid section screenshot", async ({ page }) => {
|
|
||||||
// Scroll to feature grid section - use a more reliable selector
|
|
||||||
await page.locator("text=We've got your back").scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const featureSection = page.locator("section").nth(4);
|
|
||||||
await expect(featureSection).toHaveScreenshot("feature-grid.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("quote block section screenshot", async ({ page }) => {
|
|
||||||
// Scroll to quote block section
|
|
||||||
await page.locator("text=Jo Freeman").scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const quoteSection = page.locator("section").nth(5);
|
|
||||||
await expect(quoteSection).toHaveScreenshot("quote-block.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ask organizer section screenshot", async ({ page }) => {
|
|
||||||
// Scroll to ask organizer section
|
|
||||||
await page.locator("text=Still have questions?").scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const askSection = page.locator("section").nth(6);
|
|
||||||
await expect(askSection).toHaveScreenshot("ask-organizer.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("header component screenshot", async ({ page }) => {
|
|
||||||
const header = page.locator("header");
|
|
||||||
await expect(header).toHaveScreenshot("header.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("footer component screenshot", async ({ page }) => {
|
|
||||||
// Scroll to footer
|
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Use a more specific selector for the main footer
|
|
||||||
const footer = page.locator("footer").last();
|
|
||||||
await expect(footer).toHaveScreenshot("footer.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mobile viewport screenshots", async ({ page }) => {
|
|
||||||
// Test mobile viewport
|
|
||||||
await page.setViewportSize({ width: 375, height: 667 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Wait for page to be stable
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
|
|
||||||
await expect(page).toHaveScreenshot("homepage-mobile.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test mobile hero section - use a more reliable selector
|
|
||||||
const heroSection = page.locator("section").first();
|
|
||||||
if ((await heroSection.count()) > 0) {
|
|
||||||
await heroSection.scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
await expect(heroSection).toHaveScreenshot("hero-banner-mobile.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tablet viewport screenshots", async ({ page }) => {
|
|
||||||
// Test tablet viewport
|
|
||||||
await page.setViewportSize({ width: 768, height: 1024 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Wait for page to be stable
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
|
|
||||||
await expect(page).toHaveScreenshot("homepage-tablet.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test tablet hero section - use a more reliable selector
|
|
||||||
const heroSection = page.locator("section").first();
|
|
||||||
if ((await heroSection.count()) > 0) {
|
|
||||||
await heroSection.scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
await expect(heroSection).toHaveScreenshot("hero-banner-tablet.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("desktop viewport screenshots", async ({ page }) => {
|
|
||||||
// Test desktop viewport
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
await expect(page).toHaveScreenshot("homepage-desktop.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test desktop hero section
|
|
||||||
await page.locator("text=Collaborate").scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const heroSection = page.locator("section").first();
|
|
||||||
await expect(heroSection).toHaveScreenshot("hero-banner-desktop.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("large desktop viewport screenshots", async ({ page }) => {
|
|
||||||
// Test large desktop viewport
|
|
||||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
await expect(page).toHaveScreenshot("homepage-large-desktop.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// test('button hover states', async ({ page }) => {
|
|
||||||
// // Test button hover states - scroll to hero section first to ensure button is visible
|
|
||||||
// await page.locator('text=Collaborate').scrollIntoViewIfNeeded();
|
|
||||||
// await page.waitForTimeout(500);
|
|
||||||
//
|
|
||||||
// // Use a more specific selector for the visible button
|
|
||||||
// const ctaButton = page.locator('button:has-text("Learn how CommunityRule works")').first();
|
|
||||||
//
|
|
||||||
// // Ensure button is visible
|
|
||||||
// await ctaButton.scrollIntoViewIfNeeded();
|
|
||||||
// await page.waitForTimeout(500);
|
|
||||||
//
|
|
||||||
// // Normal state
|
|
||||||
// await expect(ctaButton).toHaveScreenshot('button-normal.png', {
|
|
||||||
// animations: 'disabled'
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// // Hover state
|
|
||||||
// await ctaButton.hover();
|
|
||||||
// await page.waitForTimeout(500);
|
|
||||||
// await expect(ctaButton).toHaveScreenshot('button-hover.png', {
|
|
||||||
// animations: 'disabled'
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
test("rule card hover states", async ({ page }) => {
|
|
||||||
// Scroll to rule stack section
|
|
||||||
await page.locator("text=Consensus clusters").scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const consensusCard = page.locator('[aria-label*="Consensus clusters"]');
|
|
||||||
|
|
||||||
// Normal state
|
|
||||||
await expect(consensusCard).toHaveScreenshot("rule-card-normal.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hover state
|
|
||||||
await consensusCard.hover();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
await expect(consensusCard).toHaveScreenshot("rule-card-hover.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("feature card hover states", async ({ page }) => {
|
|
||||||
// Scroll to feature grid section
|
|
||||||
await page.locator("text=We've got your back").scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const featureCard = page.locator('a[href="#decision-making"]');
|
|
||||||
|
|
||||||
// Normal state
|
|
||||||
await expect(featureCard).toHaveScreenshot("feature-card-normal.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hover state
|
|
||||||
await featureCard.hover();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
await expect(featureCard).toHaveScreenshot("feature-card-hover.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logo hover states", async ({ page }) => {
|
|
||||||
// Scroll to logo wall section
|
|
||||||
await page
|
|
||||||
.locator("text=Trusted by leading cooperators")
|
|
||||||
.scrollIntoViewIfNeeded();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const logo = page.locator('img[alt="Food Not Bombs"]');
|
|
||||||
|
|
||||||
// Normal state
|
|
||||||
await expect(logo).toHaveScreenshot("logo-normal.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hover state
|
|
||||||
await logo.hover();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
await expect(logo).toHaveScreenshot("logo-hover.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// test('focus states', async ({ page }) => {
|
|
||||||
// // Test focus states for interactive elements - scroll to hero section first
|
|
||||||
// await page.locator('text=Collaborate').scrollIntoViewIfNeeded();
|
|
||||||
// await page.waitForTimeout(500);
|
|
||||||
//
|
|
||||||
// // Use first button and ensure it's visible
|
|
||||||
// const ctaButton = page.locator('button:has-text("Learn how CommunityRule works")').first();
|
|
||||||
//
|
|
||||||
// // Ensure button is visible
|
|
||||||
// await ctaButton.scrollIntoViewIfNeeded();
|
|
||||||
// await page.waitForTimeout(500);
|
|
||||||
//
|
|
||||||
// // Focus the button
|
|
||||||
// await ctaButton.focus();
|
|
||||||
// await page.waitForTimeout(500);
|
|
||||||
//
|
|
||||||
// await expect(ctaButton).toHaveScreenshot('button-focus.png', {
|
|
||||||
// animations: 'disabled'
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
test("loading states", async ({ page }) => {
|
|
||||||
// Test loading states by blocking resources
|
|
||||||
await page.route("**/*", (route) => {
|
|
||||||
// Delay all requests to simulate loading
|
|
||||||
setTimeout(() => route.continue(), 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reload page to trigger loading states
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Take screenshot during loading
|
|
||||||
await expect(page).toHaveScreenshot("homepage-loading.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("blog listing page", async ({ page }) => {
|
test("blog listing page", async ({ page }) => {
|
||||||
|
// Add deterministic CSS to normalize rendering
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: `
|
||||||
|
/* stop caret and selection flicker */
|
||||||
|
* { caret-color: transparent !important; }
|
||||||
|
::selection { background: transparent !important; }
|
||||||
|
/* hide scrollbars */
|
||||||
|
::-webkit-scrollbar { display: none !important; }
|
||||||
|
html { scrollbar-width: none !important; }
|
||||||
|
/* stabilize font rasterization */
|
||||||
|
* {
|
||||||
|
text-rendering: geometricPrecision !important;
|
||||||
|
-webkit-font-smoothing: antialiased !important;
|
||||||
|
-moz-osx-font-smoothing: grayscale !important;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
// Navigate to blog listing page
|
// Navigate to blog listing page
|
||||||
await page.goto("/blog");
|
await page.goto("/blog");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Wait for blog content to be fully rendered
|
// Wait for blog content to be fully rendered
|
||||||
await page.waitForSelector(
|
|
||||||
".grid.grid-cols-1.md\\:grid-cols-2.lg\\:grid-cols-3",
|
|
||||||
{ timeout: 10000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Additional wait for any dynamic content to render
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await settle(page);
|
await settle(page);
|
||||||
|
|
||||||
@@ -399,14 +130,30 @@ test.describe("Visual Regression Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("blog post page", async ({ page }) => {
|
test("blog post page", async ({ page }) => {
|
||||||
|
// Add deterministic CSS to normalize rendering
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: `
|
||||||
|
/* stop caret and selection flicker */
|
||||||
|
* { caret-color: transparent !important; }
|
||||||
|
::selection { background: transparent !important; }
|
||||||
|
/* hide scrollbars */
|
||||||
|
::-webkit-scrollbar { display: none !important; }
|
||||||
|
html { scrollbar-width: none !important; }
|
||||||
|
/* stabilize font rasterization */
|
||||||
|
* {
|
||||||
|
text-rendering: geometricPrecision !important;
|
||||||
|
-webkit-font-smoothing: antialiased !important;
|
||||||
|
-moz-osx-font-smoothing: grayscale !important;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
// Navigate to a specific blog post
|
// Navigate to a specific blog post
|
||||||
await page.goto("/blog/resolving-active-conflicts");
|
await page.goto("/blog/resolving-active-conflicts");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Wait for blog post content to be fully rendered
|
// Wait for blog post content to be fully rendered
|
||||||
await page.waitForSelector("main", { timeout: 10000 });
|
await page.waitForSelector("main", { timeout: 10000 });
|
||||||
|
|
||||||
// Additional wait for any dynamic content to render
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await settle(page);
|
await settle(page);
|
||||||
|
|
||||||
@@ -418,6 +165,24 @@ test.describe("Visual Regression Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("404 error page", async ({ page }) => {
|
test("404 error page", async ({ page }) => {
|
||||||
|
// Add deterministic CSS to normalize rendering
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: `
|
||||||
|
/* stop caret and selection flicker */
|
||||||
|
* { caret-color: transparent !important; }
|
||||||
|
::selection { background: transparent !important; }
|
||||||
|
/* hide scrollbars */
|
||||||
|
::-webkit-scrollbar { display: none !important; }
|
||||||
|
html { scrollbar-width: none !important; }
|
||||||
|
/* stabilize font rasterization */
|
||||||
|
* {
|
||||||
|
text-rendering: geometricPrecision !important;
|
||||||
|
-webkit-font-smoothing: antialiased !important;
|
||||||
|
-moz-osx-font-smoothing: grayscale !important;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
// Navigate to a non-existent route to trigger 404
|
// Navigate to a non-existent route to trigger 404
|
||||||
await page.goto("/non-existent-page");
|
await page.goto("/non-existent-page");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
@@ -428,59 +193,4 @@ test.describe("Visual Regression Tests", () => {
|
|||||||
animations: "disabled",
|
animations: "disabled",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("high contrast mode", async ({ page }) => {
|
|
||||||
// Simulate high contrast mode
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.body.style.filter = "contrast(200%)";
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page).toHaveScreenshot("homepage-high-contrast.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset contrast
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.body.style.filter = "none";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reduced motion mode", async ({ page }) => {
|
|
||||||
// Simulate reduced motion preference
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.documentElement.style.setProperty(
|
|
||||||
"--prefers-reduced-motion",
|
|
||||||
"reduce",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page).toHaveScreenshot("homepage-reduced-motion.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dark mode simulation", async ({ page }) => {
|
|
||||||
// Navigate to homepage first
|
|
||||||
await page.goto("/");
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
await settle(page);
|
|
||||||
|
|
||||||
// Simulate dark mode (if supported)
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
document.body.style.backgroundColor = "#000";
|
|
||||||
document.body.style.color = "#fff";
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page).toHaveScreenshot("homepage-dark-mode.png", {
|
|
||||||
animations: "disabled",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset to light mode
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.documentElement.classList.remove("dark");
|
|
||||||
document.body.style.backgroundColor = "";
|
|
||||||
document.body.style.color = "";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 553 KiB |
|
Before Width: | Height: | Size: 576 KiB |
|
Before Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 533 KiB |
|
Before Width: | Height: | Size: 465 KiB |
|
Before Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 574 KiB |
|
Before Width: | Height: | Size: 525 KiB |
|
Before Width: | Height: | Size: 613 KiB |
|
Before Width: | Height: | Size: 607 KiB |
|
Before Width: | Height: | Size: 608 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 341 KiB |
|
Before Width: | Height: | Size: 633 KiB |
|
Before Width: | Height: | Size: 751 KiB |
|
Before Width: | Height: | Size: 779 KiB |
|
Before Width: | Height: | Size: 781 KiB |
|
Before Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 573 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 573 KiB |
|
Before Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 28 KiB |