diff --git a/.runner.pid b/.runner.pid new file mode 100644 index 0000000..5c1838f --- /dev/null +++ b/.runner.pid @@ -0,0 +1 @@ +39731 diff --git a/TESTING_STRATEGY.md b/TESTING_STRATEGY.md new file mode 100644 index 0000000..be3cb19 --- /dev/null +++ b/TESTING_STRATEGY.md @@ -0,0 +1,257 @@ +# Testing Strategy for CommunityRule + +## Overview + +This document outlines our comprehensive testing strategy that properly separates unit testing from responsive behavior testing, following best practices for JSDOM limitations and real browser testing. + +## Current Test Status + +- **184 total tests** across the project +- **176 tests passing** (95.7% success rate) +- **8 tests failing** (all related to multiple element instances) +- **13 test files** covering all major components + +## Testing Philosophy + +### The Problem with JSDOM and Responsive Testing + +**Short take: Unit tests in JSDOM can't truly "switch breakpoints."** JSDOM doesn't evaluate CSS media queries, so Tailwind's `hidden sm:block …` won't change visibility when you "resize" the window. + +### Solution: Proper Test Separation + +- **Unit / component tests (Vitest + RTL):** assert **structure and classes**, not responsive visibility. +- **Responsive behavior:** verify with **browser-based tests** (Playwright) or **visual tests** (Chromatic/Storybook) at real viewport widths. + +## Test Categories + +### 1. Unit Tests (Vitest + React Testing Library) + +**Purpose:** Test component structure, accessibility, and configuration data. + +**What to test:** + +- DOM roles/labels exist: `role="banner"`, nav landmark, menu items +- The right **Tailwind classes** are present on wrappers (`block sm:hidden`, `hidden md:block`, etc.) +- Data-driven bits produce the expected count/order (e.g., `navigationItems`, `avatarImages`, `logoConfig`) +- Component configuration and exported data structures + +**Example:** + +```javascript +// tests/unit/Header.structure.test.js +test("logo wrappers include breakpoint classes", () => { + render(
); + const logoWrappers = screen.getAllByTestId("logo-wrapper"); + + // Check first logo variant (xs only) + expect(logoWrappers[0]).toHaveClass("block", "sm:hidden"); + + // Check second logo variant (sm only) + expect(logoWrappers[1]).toHaveClass("hidden", "sm:block", "md:hidden"); +}); +``` + +### 2. Browser-Based Tests (Playwright) + +**Purpose:** Test real responsive behavior at actual viewport widths. + +**What to test:** + +- **Visibility** at real breakpoints +- **Layout changes** between breakpoints +- **Interactive behavior** at different screen sizes +- **Accessibility** across viewports + +**Example:** + +```javascript +// tests/e2e/header.responsive.spec.js +const breakpoints = [ + { name: "xs", width: 360, height: 700 }, + { name: "sm", width: 640, height: 700 }, + { name: "md", width: 768, height: 700 }, + { name: "lg", width: 1024, height: 700 }, + { name: "xl", width: 1280, height: 700 }, +]; + +for (const bp of breakpoints) { + test(`header layout at ${bp.name}`, async ({ page }) => { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.goto("/"); + + const nav = page.getByRole("navigation", { name: /main navigation/i }); + await expect(nav).toBeVisible(); + }); +} +``` + +### 3. Visual Tests (Storybook + Chromatic) + +**Purpose:** Visual regression testing and design system validation. + +**What to test:** + +- **Visual diffs** per breakpoint +- **Design consistency** across viewports +- **Component variations** and states + +**Example:** + +```javascript +// stories/Header.responsive.stories.js +export default { + parameters: { + chromatic: { + viewports: [360, 640, 768, 1024, 1280], + delay: 100, + }, + }, +}; +``` + +## Component Improvements + +### Header Component Enhancements + +1. **Added Test IDs** for easier testing: + + ```jsx +
+ {renderLogo(config.size, config.showText)} +
+ ``` + +2. **Exported Configuration** for testing: + + ```javascript + export const navigationItems = [...]; + export const avatarImages = [...]; + export const logoConfig = [...]; + ``` + +3. **Structured Breakpoint Containers**: + ```jsx +
+
+
+ ``` + +## Test File Structure + +``` +tests/ +├── unit/ # Unit tests (Vitest + RTL) +│ ├── Header.test.jsx # CONSOLIDATED: Comprehensive Header tests +│ ├── Footer.test.jsx +│ ├── Layout.test.jsx +│ └── Page.test.jsx +├── integration/ # Integration tests +│ └── ContentLockup.integration.test.jsx +├── e2e/ # Browser tests (Playwright) +│ └── header.responsive.spec.js # NEW: Responsive behavior tests +└── stories/ # Storybook stories + └── Header.responsive.stories.js # NEW: Visual testing +``` + +## Best Practices + +### Unit Testing (JSDOM) + +1. **Test structure, not visibility**: + + ```javascript + // ✅ Good: Test classes exist + expect(element).toHaveClass("block", "sm:hidden"); + + // ❌ Bad: Test visibility (doesn't work in JSDOM) + expect(element).toBeVisible(); + ``` + +2. **Use test IDs for containers**: + + ```javascript + // ✅ Good: Test specific containers + const logoWrapper = screen.getByTestId("logo-wrapper"); + + // ❌ Bad: Query by complex class strings + const logoWrapper = document.querySelector(".block.sm\\:hidden"); + ``` + +3. **Test configuration data**: + ```javascript + // ✅ Good: Test exported configuration + expect(navigationItems).toHaveLength(3); + expect(logoConfig).toHaveLength(5); + ``` + +### Browser Testing (Playwright) + +1. **Test real viewport sizes**: + + ```javascript + await page.setViewportSize({ width: 640, height: 700 }); + ``` + +2. **Test visibility at breakpoints**: + + ```javascript + if (bp.name === "xs") { + await expect(page.getByTestId("auth-xs")).toBeVisible(); + } + ``` + +3. **Test accessibility across viewports**: + + ```javascript + const interactiveElements = [ + page.getByRole("link", { name: /use cases/i }), + page.getByRole("button", { name: /create rule/i }), + ]; + + for (const element of interactiveElements) { + await expect(element).toBeVisible(); + await expect(element).toBeEnabled(); + } + ``` + +## Running Tests + +### Unit Tests + +```bash +npm test # Run all unit tests +npm test tests/unit/ # Run only unit tests +npm test Header.structure # Run specific test file +``` + +### Browser Tests + +```bash +npx playwright test # Run all browser tests +npx playwright test header.responsive.spec.js # Run specific test +``` + +### Visual Tests + +```bash +npm run storybook # Start Storybook +npx chromatic --project-token=xxx # Run visual tests +``` + +## Future Improvements + +1. **Add more Playwright tests** for other components +2. **Set up Chromatic** for visual regression testing +3. **Add performance tests** for responsive behavior +4. **Create component-specific test utilities** +5. **Add accessibility testing** with axe-core + +## Key Takeaways + +1. **JSDOM limitations** require separating structure tests from visibility tests +2. **Test IDs** make testing more reliable and maintainable +3. **Exported configuration** enables better data structure testing +4. **Real browser testing** is essential for responsive behavior +5. **Visual testing** catches design regressions across breakpoints + +This strategy provides comprehensive coverage while respecting the limitations of different testing environments. diff --git a/app/components/Header.js b/app/components/Header.js index 0579285..601d48f 100644 --- a/app/components/Header.js +++ b/app/components/Header.js @@ -5,6 +5,35 @@ import Button from "./Button"; import AvatarContainer from "./AvatarContainer"; import Avatar from "./Avatar"; +// Configuration data for testing +export const navigationItems = [ + { href: "#", text: "Use cases", extraPadding: true }, + { href: "#", text: "Learn" }, + { href: "#", text: "About" }, +]; + +export const avatarImages = [ + { src: "assets/Avatar_1.png", alt: "Avatar 1" }, + { src: "assets/Avatar_2.png", alt: "Avatar 2" }, + { src: "assets/Avatar_3.png", alt: "Avatar 3" }, +]; + +export const logoConfig = [ + { breakpoint: "block sm:hidden", size: "header", showText: false }, + { breakpoint: "hidden sm:block md:hidden", size: "header", showText: true }, + { + breakpoint: "hidden md:block lg:hidden", + size: "headerMd", + showText: true, + }, + { + breakpoint: "hidden lg:block xl:hidden", + size: "headerLg", + showText: true, + }, + { breakpoint: "hidden xl:block", size: "headerXl", showText: true }, +]; + export default function Header({ onToggle }) { // Schema markup for site navigation const schemaData = { @@ -18,33 +47,6 @@ export default function Header({ onToggle }) { "query-input": "required name=search_term_string", }, }; - const navigationItems = [ - { href: "#", text: "Use cases", extraPadding: true }, - { href: "#", text: "Learn" }, - { href: "#", text: "About" }, - ]; - - const avatarImages = [ - { src: "assets/Avatar_1.png", alt: "Avatar 1" }, - { src: "assets/Avatar_2.png", alt: "Avatar 2" }, - { src: "assets/Avatar_3.png", alt: "Avatar 3" }, -]; - - const logoConfig = [ - { breakpoint: "block sm:hidden", size: "header", showText: false }, - { breakpoint: "hidden sm:block md:hidden", size: "header", showText: true }, - { - breakpoint: "hidden md:block lg:hidden", - size: "headerMd", - showText: true, - }, - { - breakpoint: "hidden lg:block xl:hidden", - size: "headerLg", - showText: true, - }, - { breakpoint: "hidden xl:block", size: "headerXl", showText: true }, - ]; const renderNavigationItems = (size) => { return navigationItems.map((item, index) => ( @@ -118,7 +120,11 @@ export default function Header({ onToggle }) { {/* Logo - Consistent left positioning across all breakpoints */}
{logoConfig.map((config, index) => ( -
+
{renderLogo(config.size, config.showText)}
))} @@ -127,29 +133,29 @@ export default function Header({ onToggle }) { {/* Navigation Links - Consistent center positioning */}
{/* XSmall breakpoint - Navigation items moved to right section */} -
+
{/* Empty for XSmall - navigation moved to right */}
{/* Small breakpoint - All items grouped together, centered */} -
+
{renderNavigationItems("xsmall")} {renderLoginButton("xsmall")}
-
+
{renderNavigationItems("xsmall")}
-
+
{renderNavigationItems("large")}
-
+
{renderNavigationItems("xlarge")}
@@ -157,7 +163,7 @@ export default function Header({ onToggle }) { {/* Authentication Elements - Consistent right alignment across all breakpoints */}
{/* XSmall breakpoint - All navigation items + Create Rule button */} -
+
{renderNavigationItems("xsmall")} {renderLoginButton("xsmall")} @@ -166,14 +172,14 @@ export default function Header({ onToggle }) {
{/* Small breakpoint - Only Create Rule button */} -
+
{renderCreateRuleButton("xsmall", "small", "small")}
{/* Medium breakpoint */} -
+
{renderLoginButton("xsmall")} {renderCreateRuleButton("xsmall", "medium", "medium")} @@ -181,7 +187,7 @@ export default function Header({ onToggle }) {
{/* Large breakpoint */} -
+
{renderLoginButton("large")} {renderCreateRuleButton("large", "xlarge", "xlarge")} @@ -189,7 +195,7 @@ export default function Header({ onToggle }) {
{/* XLarge breakpoint */} -
+
{renderLoginButton("xlarge")} {renderCreateRuleButton("xlarge", "xlarge", "xlarge")} diff --git a/app/components/Logo.js b/app/components/Logo.js index ae8b8ac..a3a6866 100644 --- a/app/components/Logo.js +++ b/app/components/Logo.js @@ -117,7 +117,6 @@ export default function Logo({ size = "default", showText = true }) { className={`flex items-center ${config.containerHeight} ${ showText ? config.gap : "" } transition-all duration-200 ease-in-out hover:scale-[1.02] cursor-pointer`} - role="banner" aria-label="CommunityRule Logo" > {/* Logo Text - only show if showText is true */} diff --git a/stories/Header.responsive.stories.js b/stories/Header.responsive.stories.js new file mode 100644 index 0000000..b20f0bd --- /dev/null +++ b/stories/Header.responsive.stories.js @@ -0,0 +1,200 @@ +import Header from "../app/components/Header.js"; +import { within, userEvent } from "@storybook/testing-library"; + +export default { + title: "Components/Header/Responsive", + component: Header, + parameters: { + // Chromatic configuration for responsive testing + chromatic: { + viewports: [360, 640, 768, 1024, 1280], + // Capture screenshots at each breakpoint + delay: 100, // Small delay to ensure layout is stable + }, + // Storybook viewport configuration + viewport: { + viewports: { + xs: { + name: "Extra Small (xs)", + styles: { + width: "360px", + height: "700px", + }, + }, + sm: { + name: "Small (sm)", + styles: { + width: "640px", + height: "700px", + }, + }, + md: { + name: "Medium (md)", + styles: { + width: "768px", + height: "700px", + }, + }, + lg: { + name: "Large (lg)", + styles: { + width: "1024px", + height: "700px", + }, + }, + xl: { + name: "Extra Large (xl)", + styles: { + width: "1280px", + height: "700px", + }, + }, + }, + }, + }, + argTypes: { + onToggle: { action: "toggled" }, + }, +}; + +// Default story - will be captured at all viewports by Chromatic +export const Default = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + parameters: { + docs: { + description: { + story: + "Header component at different breakpoints. Chromatic will capture screenshots at 360px, 640px, 768px, 1024px, and 1280px viewport widths to test responsive behavior.", + }, + }, + }, +}; + +// Story for each breakpoint to make testing easier +export const ExtraSmall = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + parameters: { + viewport: { + defaultViewport: "xs", + }, + docs: { + description: { + story: + "Header at extra small breakpoint (360px). Navigation items are moved to the right section.", + }, + }, + }, +}; + +export const Small = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + parameters: { + viewport: { + defaultViewport: "sm", + }, + docs: { + description: { + story: + "Header at small breakpoint (640px). All navigation items are grouped together in the center.", + }, + }, + }, +}; + +export const Medium = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + parameters: { + viewport: { + defaultViewport: "md", + }, + docs: { + description: { + story: + "Header at medium breakpoint (768px). Navigation items are in the center, login and create rule buttons on the right.", + }, + }, + }, +}; + +export const Large = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + parameters: { + viewport: { + defaultViewport: "lg", + }, + docs: { + description: { + story: + "Header at large breakpoint (1024px). Full navigation layout with larger elements.", + }, + }, + }, +}; + +export const ExtraLarge = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + parameters: { + viewport: { + defaultViewport: "xl", + }, + docs: { + description: { + story: + "Header at extra large breakpoint (1280px). Maximum size layout with largest elements.", + }, + }, + }, +}; + +// Interactive story for testing user interactions +export const Interactive = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + parameters: { + docs: { + description: { + story: + "Interactive header for testing user interactions. Check the Actions panel to see triggered events.", + }, + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Click navigation items", async () => { + const useCasesLink = canvas.getByRole("link", { name: /use cases/i }); + await userEvent.click(useCasesLink); + + const learnLink = canvas.getByRole("link", { name: /learn/i }); + await userEvent.click(learnLink); + + const aboutLink = canvas.getByRole("link", { name: /about/i }); + await userEvent.click(aboutLink); + }); + + await step("Click authentication elements", async () => { + const loginLink = canvas.getByRole("link", { + name: /log in to your account/i, + }); + await userEvent.click(loginLink); + + const createRuleButton = canvas.getByRole("button", { + name: /create a new rule with avatar decoration/i, + }); + await userEvent.click(createRuleButton); + }); + }, +}; diff --git a/tests/e2e/header.responsive.spec.js b/tests/e2e/header.responsive.spec.js new file mode 100644 index 0000000..0317ddf --- /dev/null +++ b/tests/e2e/header.responsive.spec.js @@ -0,0 +1,185 @@ +import { test, expect } from "@playwright/test"; + +const breakpoints = [ + { name: "xs", width: 360, height: 700 }, + { name: "sm", width: 640, height: 700 }, + { name: "md", width: 768, height: 700 }, + { name: "lg", width: 1024, height: 700 }, + { name: "xl", width: 1280, height: 700 }, +]; + +for (const bp of breakpoints) { + test.describe(`Header responsive behavior at ${bp.name} breakpoint`, () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.goto("/"); + }); + + test(`header layout at ${bp.name}`, async ({ page }) => { + const nav = page.getByRole("navigation", { name: /main navigation/i }); + await expect(nav).toBeVisible(); + + // Check that header banner is visible + const header = page.getByRole("banner", { + name: /main navigation header/i, + }); + await expect(header).toBeVisible(); + }); + + test(`navigation items visibility at ${bp.name}`, async ({ page }) => { + // All breakpoints should have navigation items + await expect( + page.getByRole("link", { name: /use cases/i }) + ).toBeVisible(); + await expect(page.getByRole("link", { name: /learn/i })).toBeVisible(); + await expect(page.getByRole("link", { name: /about/i })).toBeVisible(); + }); + + test(`authentication elements visibility at ${bp.name}`, async ({ + page, + }) => { + // All breakpoints should have login button + await expect( + page.getByRole("link", { name: /log in to your account/i }) + ).toBeVisible(); + + // All breakpoints should have create rule button + await expect( + page.getByRole("button", { + name: /create a new rule with avatar decoration/i, + }) + ).toBeVisible(); + }); + + test(`logo visibility at ${bp.name}`, async ({ page }) => { + // Logo should be visible at all breakpoints + const logo = page.locator('[data-testid="logo-wrapper"]').first(); + await expect(logo).toBeVisible(); + }); + + // Breakpoint-specific tests + if (bp.name === "xs") { + test("xs breakpoint specific behavior", async ({ page }) => { + // At xs, navigation items should be in the right section + const authXs = page.locator('[data-testid="auth-xs"]'); + await expect(authXs).toBeVisible(); + + // Navigation items should be in the auth section at xs + const useCasesLink = page.getByRole("link", { name: /use cases/i }); + await expect(useCasesLink).toBeVisible(); + + // Login button should be in the auth section + const loginButton = page.getByRole("link", { + name: /log in to your account/i, + }); + await expect(loginButton).toBeVisible(); + + // Create rule button should be visible + const createRuleButton = page.getByRole("button", { + name: /create a new rule with avatar decoration/i, + }); + await expect(createRuleButton).toBeVisible(); + }); + } + + if (bp.name === "sm") { + test("sm breakpoint specific behavior", async ({ page }) => { + // At sm, navigation should be in the center section + const navSm = page.locator('[data-testid="nav-sm"]'); + await expect(navSm).toBeVisible(); + + // Auth section should only have create rule button + const authSm = page.locator('[data-testid="auth-sm"]'); + await expect(authSm).toBeVisible(); + }); + } + + if (bp.name === "md") { + test("md breakpoint specific behavior", async ({ page }) => { + // At md, navigation should be in the center section + const navMd = page.locator('[data-testid="nav-md"]'); + await expect(navMd).toBeVisible(); + + // Auth section should have login and create rule button + const authMd = page.locator('[data-testid="auth-md"]'); + await expect(authMd).toBeVisible(); + }); + } + + if (bp.name === "lg") { + test("lg breakpoint specific behavior", async ({ page }) => { + // At lg, navigation should be in the center section + const navLg = page.locator('[data-testid="nav-lg"]'); + await expect(navLg).toBeVisible(); + + // Auth section should have login and create rule button + const authLg = page.locator('[data-testid="auth-lg"]'); + await expect(authLg).toBeVisible(); + }); + } + + if (bp.name === "xl") { + test("xl breakpoint specific behavior", async ({ page }) => { + // At xl, navigation should be in the center section + const navXl = page.locator('[data-testid="nav-xl"]'); + await expect(navXl).toBeVisible(); + + // Auth section should have login and create rule button + const authXl = page.locator('[data-testid="auth-xl"]'); + await expect(authXl).toBeVisible(); + }); + } + }); +} + +// Additional responsive behavior tests +test.describe("Header responsive behavior", () => { + test("header maintains proper layout across breakpoints", async ({ + page, + }) => { + // Test that header doesn't break at edge cases + const edgeCases = [ + { width: 320, height: 700 }, // Very small + { width: 1920, height: 700 }, // Very large + ]; + + for (const viewport of edgeCases) { + await page.setViewportSize(viewport); + await page.goto("/"); + + const header = page.getByRole("banner", { + name: /main navigation header/i, + }); + await expect(header).toBeVisible(); + + const nav = page.getByRole("navigation", { name: /main navigation/i }); + await expect(nav).toBeVisible(); + } + }); + + test("header elements are properly accessible across breakpoints", async ({ + page, + }) => { + // Test accessibility at different breakpoints + for (const bp of breakpoints) { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.goto("/"); + + // Check that all interactive elements are accessible + const interactiveElements = [ + page.getByRole("link", { name: /use cases/i }), + page.getByRole("link", { name: /learn/i }), + page.getByRole("link", { name: /about/i }), + page.getByRole("link", { name: /log in to your account/i }), + page.getByRole("button", { + name: /create a new rule with avatar decoration/i, + }), + ]; + + for (const element of interactiveElements) { + await expect(element).toBeVisible(); + await expect(element).toBeEnabled(); + } + } + }); +}); diff --git a/tests/unit/Footer.test.jsx b/tests/unit/Footer.test.jsx new file mode 100644 index 0000000..902215e --- /dev/null +++ b/tests/unit/Footer.test.jsx @@ -0,0 +1,253 @@ +import { describe, test, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Footer from "../../app/components/Footer"; + +describe("Footer", () => { + test("renders footer with correct structure", () => { + render(