From f7621d208625c54bc8ea3ddb2908b34d871e01c7 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 29 Aug 2025 13:42:25 -0600 Subject: [PATCH] Visual regression tests refined --- stories/Button.visual.stories.js | 353 +++++++++++++++++++++++ stories/Footer.responsive.stories.js | 335 +++++++++++++++++++++ stories/Header.responsive.stories.js | 186 +++++++++++- tests/e2e/footer.responsive.spec.js | 242 ++++++++++++++++ tests/e2e/header.responsive.spec.js | 97 ++++++- tests/visual/visual-regression.config.js | 215 ++++++++++++++ 6 files changed, 1421 insertions(+), 7 deletions(-) create mode 100644 stories/Button.visual.stories.js create mode 100644 stories/Footer.responsive.stories.js create mode 100644 tests/e2e/footer.responsive.spec.js create mode 100644 tests/visual/visual-regression.config.js diff --git a/stories/Button.visual.stories.js b/stories/Button.visual.stories.js new file mode 100644 index 0000000..52a6317 --- /dev/null +++ b/stories/Button.visual.stories.js @@ -0,0 +1,353 @@ +import Button from "../app/components/Button.js"; +import { within, userEvent } from "@storybook/testing-library"; + +export default { + title: "Components/Button/Visual Regression", + component: Button, + parameters: { + // Chromatic configuration for visual testing + chromatic: { + viewports: [320, 640, 1024, 1280], + delay: 200, + modes: { + light: {}, + dark: { + colorScheme: "dark", + }, + }, + }, + }, + argTypes: { + size: { + control: { type: "select" }, + options: ["xsmall", "small", "medium", "large", "xlarge"], + }, + variant: { + control: { type: "select" }, + options: ["default", "home"], + }, + disabled: { + control: { type: "boolean" }, + }, + }, +}; + +// Default button states +export const Default = { + args: { + children: "Default Button", + }, + parameters: { + docs: { + description: { + story: "Default button state for visual regression testing.", + }, + }, + }, +}; + +export const Hover = { + args: { + children: "Hover Button", + }, + parameters: { + docs: { + description: { + story: "Button in hover state for visual regression testing.", + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button"); + await userEvent.hover(button); + await new Promise((resolve) => setTimeout(resolve, 100)); + }, +}; + +export const Focus = { + args: { + children: "Focus Button", + }, + parameters: { + docs: { + description: { + story: "Button in focus state for visual regression testing.", + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button"); + button.focus(); + await new Promise((resolve) => setTimeout(resolve, 100)); + }, +}; + +export const Active = { + args: { + children: "Active Button", + }, + parameters: { + docs: { + description: { + story: "Button in active/pressed state for visual regression testing.", + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button"); + await userEvent.click(button); + await new Promise((resolve) => setTimeout(resolve, 100)); + }, +}; + +export const Disabled = { + args: { + children: "Disabled Button", + disabled: true, + }, + parameters: { + docs: { + description: { + story: "Disabled button state for visual regression testing.", + }, + }, + }, +}; + +// Size variants +export const XSmall = { + args: { + children: "XSmall Button", + size: "xsmall", + }, + parameters: { + docs: { + description: { + story: "Extra small button size for visual regression testing.", + }, + }, + }, +}; + +export const Small = { + args: { + children: "Small Button", + size: "small", + }, + parameters: { + docs: { + description: { + story: "Small button size for visual regression testing.", + }, + }, + }, +}; + +export const Medium = { + args: { + children: "Medium Button", + size: "medium", + }, + parameters: { + docs: { + description: { + story: "Medium button size for visual regression testing.", + }, + }, + }, +}; + +export const Large = { + args: { + children: "Large Button", + size: "large", + }, + parameters: { + docs: { + description: { + story: "Large button size for visual regression testing.", + }, + }, + }, +}; + +export const XLarge = { + args: { + children: "XLarge Button", + size: "xlarge", + }, + parameters: { + docs: { + description: { + story: "Extra large button size for visual regression testing.", + }, + }, + }, +}; + +// Variant styles +export const HomeVariant = { + args: { + children: "Home Button", + variant: "home", + }, + parameters: { + docs: { + description: { + story: "Home variant button for visual regression testing.", + }, + }, + }, +}; + +// Button with icon/content +export const WithIcon = { + args: { + children: ( + <> + Button with Icon + + + + + ), + }, + parameters: { + docs: { + description: { + story: "Button with icon for visual regression testing.", + }, + }, + }, +}; + +export const LongText = { + args: { + children: + "This is a button with very long text content that might wrap or overflow", + }, + parameters: { + docs: { + description: { + story: "Button with long text for visual regression testing.", + }, + }, + }, +}; + +// Button grid for comparison +export const SizeComparison = { + render: () => ( +
+
+ + + + + +
+
+ ), + parameters: { + docs: { + description: { + story: "All button sizes for comparison and visual regression testing.", + }, + }, + }, +}; + +export const StateComparison = { + render: () => ( +
+
+ + +
+
+ + +
+
+ ), + parameters: { + docs: { + description: { + story: "Button states for comparison and visual regression testing.", + }, + }, + }, +}; + +// Interactive states +export const InteractiveStates = { + render: () => ( +
+
+ + + +
+
+ ), + parameters: { + docs: { + description: { + story: "Interactive button states for visual regression testing.", + }, + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Test hover state", async () => { + const hoverButton = canvas.getByRole("button", { name: "Hover Me" }); + await userEvent.hover(hoverButton); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await step("Test focus state", async () => { + const focusButton = canvas.getByRole("button", { name: "Focus Me" }); + focusButton.focus(); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await step("Test click state", async () => { + const clickButton = canvas.getByRole("button", { name: "Click Me" }); + await userEvent.click(clickButton); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + }, +}; + +// Edge cases +export const EdgeCases = { + render: () => ( +
+
+ + +
+
+ + + + +
+
+ ), + parameters: { + docs: { + description: { + story: "Edge cases for button visual regression testing.", + }, + }, + }, +}; diff --git a/stories/Footer.responsive.stories.js b/stories/Footer.responsive.stories.js new file mode 100644 index 0000000..77b5b96 --- /dev/null +++ b/stories/Footer.responsive.stories.js @@ -0,0 +1,335 @@ +import Footer from "../app/components/Footer.js"; +import { within, userEvent } from "@storybook/testing-library"; + +export default { + title: "Components/Footer/Responsive", + component: Footer, + parameters: { + // Chromatic configuration for responsive testing + chromatic: { + viewports: [320, 360, 480, 640, 768, 1024, 1280, 1440, 1920], + // Capture screenshots at each breakpoint + delay: 200, // Increased delay to ensure layout is stable + // Capture both light and dark themes if available + modes: { + light: {}, + dark: { + // This will be used if dark mode is implemented + colorScheme: "dark", + }, + }, + }, + // 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", + }, + }, + xxl: { + name: "2XL (xxl)", + styles: { + width: "1440px", + height: "700px", + }, + }, + full: { + name: "Full HD (full)", + styles: { + width: "1920px", + height: "700px", + }, + }, + }, + }, + }, +}; + +// Default story - will be captured at all viewports by Chromatic +export const Default = { + parameters: { + docs: { + description: { + story: + "Footer component at different breakpoints. Chromatic will capture screenshots at 320px, 360px, 480px, 640px, 768px, 1024px, 1280px, 1440px, and 1920px viewport widths to test responsive behavior.", + }, + }, + }, +}; + +// Story for each breakpoint to make testing easier +export const ExtraSmall = { + parameters: { + viewport: { + defaultViewport: "xs", + }, + docs: { + description: { + story: + "Footer at extra small breakpoint (360px). Tests mobile layout and stacking behavior.", + }, + }, + }, +}; + +export const Small = { + parameters: { + viewport: { + defaultViewport: "sm", + }, + docs: { + description: { + story: + "Footer at small breakpoint (640px). Tests tablet layout and responsive behavior.", + }, + }, + }, +}; + +export const Medium = { + parameters: { + viewport: { + defaultViewport: "md", + }, + docs: { + description: { + story: + "Footer at medium breakpoint (768px). Tests small desktop layout.", + }, + }, + }, +}; + +export const Large = { + parameters: { + viewport: { + defaultViewport: "lg", + }, + docs: { + description: { + story: "Footer at large breakpoint (1024px). Tests desktop layout.", + }, + }, + }, +}; + +export const ExtraLarge = { + parameters: { + viewport: { + defaultViewport: "xl", + }, + docs: { + description: { + story: + "Footer at extra large breakpoint (1280px). Tests large desktop layout.", + }, + }, + }, +}; + +export const TwoXL = { + parameters: { + viewport: { + defaultViewport: "xxl", + }, + docs: { + description: { + story: + "Footer at 2XL breakpoint (1440px). Tests very large desktop layout.", + }, + }, + }, +}; + +export const FullHD = { + parameters: { + viewport: { + defaultViewport: "full", + }, + docs: { + description: { + story: + "Footer at Full HD breakpoint (1920px). Tests maximum desktop layout.", + }, + }, + }, +}; + +// Interactive story for testing user interactions +export const Interactive = { + parameters: { + docs: { + description: { + story: + "Interactive footer for testing user interactions. Check the Actions panel to see triggered events.", + }, + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Click footer links", 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); + + const privacyLink = canvas.getByRole("link", { name: /privacy policy/i }); + await userEvent.click(privacyLink); + + const termsLink = canvas.getByRole("link", { name: /terms of service/i }); + await userEvent.click(termsLink); + }); + + await step("Click social media links", async () => { + const blueskyLink = canvas.getByRole("link", { + name: /follow us on bluesky/i, + }); + await userEvent.click(blueskyLink); + + const gitlabLink = canvas.getByRole("link", { + name: /follow us on gitlab/i, + }); + await userEvent.click(gitlabLink); + }); + }, +}; + +// Story for testing hover states +export const HoverStates = { + parameters: { + docs: { + description: { + story: + "Footer with hover states visible. This story captures the visual appearance when elements are hovered.", + }, + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Hover over footer links", async () => { + const useCasesLink = canvas.getByRole("link", { name: /use cases/i }); + await userEvent.hover(useCasesLink); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const learnLink = canvas.getByRole("link", { name: /learn/i }); + await userEvent.hover(learnLink); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const aboutLink = canvas.getByRole("link", { name: /about/i }); + await userEvent.hover(aboutLink); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await step("Hover over social media links", async () => { + const blueskyLink = canvas.getByRole("link", { + name: /follow us on bluesky/i, + }); + await userEvent.hover(blueskyLink); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const gitlabLink = canvas.getByRole("link", { + name: /follow us on gitlab/i, + }); + await userEvent.hover(gitlabLink); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + }, +}; + +// Story for testing with long content above +export const WithLongContent = { + render: () => ( +
+
+
+

+ Footer with Long Content Above +

+

+ This story tests how the footer looks with a lot of content above + it. This helps ensure the footer maintains its visual integrity in + real-world scenarios. +

+
+ {Array.from({ length: 20 }, (_, i) => ( +
+

+ Content Block {i + 1} +

+

+ This is example content to show how the footer integrates with + page content. This block contains enough text to test layout + behavior. +

+
+ ))} +
+
+
+
+ ), + parameters: { + docs: { + description: { + story: + "Footer with long content above to test visual integration and layout stability.", + }, + }, + }, +}; + +// Story for testing edge cases +export const EdgeCases = { + parameters: { + viewport: { + defaultViewport: "xs", + }, + docs: { + description: { + story: + "Footer at the smallest breakpoint to test edge case behavior and ensure no layout issues.", + }, + }, + }, +}; diff --git a/stories/Header.responsive.stories.js b/stories/Header.responsive.stories.js index b20f0bd..142b15f 100644 --- a/stories/Header.responsive.stories.js +++ b/stories/Header.responsive.stories.js @@ -7,9 +7,17 @@ export default { parameters: { // Chromatic configuration for responsive testing chromatic: { - viewports: [360, 640, 768, 1024, 1280], + viewports: [320, 360, 480, 640, 768, 1024, 1280, 1440, 1920], // Capture screenshots at each breakpoint - delay: 100, // Small delay to ensure layout is stable + delay: 200, // Increased delay to ensure layout is stable + // Capture both light and dark themes if available + modes: { + light: {}, + dark: { + // This will be used if dark mode is implemented + colorScheme: "dark", + }, + }, }, // Storybook viewport configuration viewport: { @@ -49,6 +57,20 @@ export default { height: "700px", }, }, + xxl: { + name: "2XL (xxl)", + styles: { + width: "1440px", + height: "700px", + }, + }, + full: { + name: "Full HD (full)", + styles: { + width: "1920px", + height: "700px", + }, + }, }, }, }, @@ -198,3 +220,163 @@ export const Interactive = { }); }, }; + +// Story for testing hover states +export const HoverStates = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + parameters: { + docs: { + description: { + story: + "Header with hover states visible. This story captures the visual appearance when elements are hovered.", + }, + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Hover over navigation items", async () => { + const useCasesLink = canvas.getByRole("link", { name: /use cases/i }); + await userEvent.hover(useCasesLink); + // Wait for hover state to be visible + await new Promise((resolve) => setTimeout(resolve, 100)); + + const learnLink = canvas.getByRole("link", { name: /learn/i }); + await userEvent.hover(learnLink); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const aboutLink = canvas.getByRole("link", { name: /about/i }); + await userEvent.hover(aboutLink); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await step("Hover over authentication elements", async () => { + const loginLink = canvas.getByRole("link", { + name: /log in to your account/i, + }); + await userEvent.hover(loginLink); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const createRuleButton = canvas.getByRole("button", { + name: /create a new rule with avatar decoration/i, + }); + await userEvent.hover(createRuleButton); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + }, +}; + +// Story for testing focus states +export const FocusStates = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + parameters: { + docs: { + description: { + story: + "Header with focus states visible. This story captures the visual appearance when elements are focused.", + }, + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Focus on navigation items", async () => { + const useCasesLink = canvas.getByRole("link", { name: /use cases/i }); + useCasesLink.focus(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const learnLink = canvas.getByRole("link", { name: /learn/i }); + learnLink.focus(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const aboutLink = canvas.getByRole("link", { name: /about/i }); + aboutLink.focus(); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await step("Focus on authentication elements", async () => { + const loginLink = canvas.getByRole("link", { + name: /log in to your account/i, + }); + loginLink.focus(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const createRuleButton = canvas.getByRole("button", { + name: /create a new rule with avatar decoration/i, + }); + createRuleButton.focus(); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + }, +}; + +// Story for testing with long content +export const WithLongContent = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + render: () => ( +
+
console.log("Navigation toggled")} /> +
+
+

+ Header with Long Content +

+

+ This story tests how the header looks with a lot of content below + it. This helps ensure the header maintains its visual integrity in + real-world scenarios. +

+
+ {Array.from({ length: 12 }, (_, i) => ( +
+

+ Content Block {i + 1} +

+

+ This is example content to show how the header integrates with + page content. This block contains enough text to test layout + behavior. +

+
+ ))} +
+
+
+
+ ), + parameters: { + docs: { + description: { + story: + "Header with long content below to test visual integration and layout stability.", + }, + }, + }, +}; + +// Story for testing edge cases +export const EdgeCases = { + args: { + onToggle: () => console.log("Navigation toggled"), + }, + parameters: { + viewport: { + defaultViewport: "xs", + }, + docs: { + description: { + story: + "Header at the smallest breakpoint to test edge case behavior and ensure no layout issues.", + }, + }, + }, +}; diff --git a/tests/e2e/footer.responsive.spec.js b/tests/e2e/footer.responsive.spec.js new file mode 100644 index 0000000..c19d98b --- /dev/null +++ b/tests/e2e/footer.responsive.spec.js @@ -0,0 +1,242 @@ +import { test, expect } from "@playwright/test"; + +const breakpoints = [ + { name: "xs", width: 320, height: 700 }, + { name: "sm", width: 360, height: 700 }, + { name: "md", width: 480, height: 700 }, + { name: "lg", width: 640, height: 700 }, + { name: "xl", width: 768, height: 700 }, + { name: "2xl", width: 1024, height: 700 }, + { name: "3xl", width: 1280, height: 700 }, + { name: "4xl", width: 1440, height: 700 }, + { name: "full", width: 1920, height: 700 }, +]; + +for (const bp of breakpoints) { + test.describe(`Footer responsive behavior at ${bp.name} breakpoint`, () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.goto("/"); + }); + + test(`footer layout at ${bp.name}`, async ({ page }) => { + const footer = page.getByRole("contentinfo"); + await expect(footer).toBeVisible(); + + // Check that footer content is visible + const footerContent = page.locator("footer"); + await expect(footerContent).toBeVisible(); + }); + + test(`footer 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(`footer legal links visibility at ${bp.name}`, async ({ page }) => { + // All breakpoints should have legal links + await expect( + page.getByRole("link", { name: /privacy policy/i }) + ).toBeVisible(); + await expect( + page.getByRole("link", { name: /terms of service/i }) + ).toBeVisible(); + }); + + test(`footer social links visibility at ${bp.name}`, async ({ page }) => { + // All breakpoints should have social links + await expect( + page.getByRole("link", { name: /follow us on bluesky/i }) + ).toBeVisible(); + await expect( + page.getByRole("link", { name: /follow us on gitlab/i }) + ).toBeVisible(); + }); + + test(`footer 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, footer should stack vertically + const footer = page.locator("footer"); + await expect(footer).toBeVisible(); + + // Check that content is properly stacked + const footerContent = page.locator("footer > div"); + await expect(footerContent).toBeVisible(); + }); + } + + if (bp.name === "md") { + test("md breakpoint specific behavior", async ({ page }) => { + // At md, footer should have proper spacing + const footer = page.locator("footer"); + await expect(footer).toBeVisible(); + }); + } + + if (bp.name === "xl") { + test("xl breakpoint specific behavior", async ({ page }) => { + // At xl, footer should have full layout + const footer = page.locator("footer"); + await expect(footer).toBeVisible(); + }); + } + }); +} + +// Visual regression tests +test.describe("Footer visual regression", () => { + test("footer visual consistency across breakpoints", async ({ page }) => { + // Test visual consistency at all breakpoints + for (const bp of breakpoints) { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.goto("/"); + + // Scroll to footer + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(500); + + // Take a screenshot for visual regression testing + await expect(page.locator("footer").first()).toHaveScreenshot( + `footer-${bp.name}.png` + ); + } + }); + + test("footer hover states visual consistency", async ({ page }) => { + // Test hover states at key breakpoints + const keyBreakpoints = [ + { name: "xs", width: 320, height: 700 }, + { name: "md", width: 768, height: 700 }, + { name: "xl", width: 1280, height: 700 }, + ]; + + for (const bp of keyBreakpoints) { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.goto("/"); + + // Scroll to footer + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(500); + + // Test hover on navigation items + const useCasesLink = page.getByRole("link", { name: /use cases/i }); + await useCasesLink.hover(); + await page.waitForTimeout(200); + await expect(page.locator("footer").first()).toHaveScreenshot( + `footer-${bp.name}-hover-nav.png` + ); + + // Test hover on social links + const blueskyLink = page.getByRole("link", { + name: /follow us on bluesky/i, + }); + await blueskyLink.hover(); + await page.waitForTimeout(200); + await expect(page.locator("footer").first()).toHaveScreenshot( + `footer-${bp.name}-hover-social.png` + ); + } + }); + + test("footer focus states visual consistency", async ({ page }) => { + // Test focus states at key breakpoints + const keyBreakpoints = [ + { name: "xs", width: 320, height: 700 }, + { name: "md", width: 768, height: 700 }, + { name: "xl", width: 1280, height: 700 }, + ]; + + for (const bp of keyBreakpoints) { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.goto("/"); + + // Scroll to footer + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(500); + + // Test focus on navigation items + const useCasesLink = page.getByRole("link", { name: /use cases/i }); + await useCasesLink.focus(); + await page.waitForTimeout(200); + await expect(page.locator("footer").first()).toHaveScreenshot( + `footer-${bp.name}-focus-nav.png` + ); + + // Test focus on social links + const blueskyLink = page.getByRole("link", { + name: /follow us on bluesky/i, + }); + await blueskyLink.focus(); + await page.waitForTimeout(200); + await expect(page.locator("footer").first()).toHaveScreenshot( + `footer-${bp.name}-focus-social.png` + ); + } + }); +}); + +// Additional responsive behavior tests +test.describe("Footer responsive behavior", () => { + test("footer maintains proper layout across breakpoints", async ({ + page, + }) => { + // Test that footer 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("/"); + + // Scroll to footer + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + const footer = page.getByRole("contentinfo"); + await expect(footer).toBeVisible(); + } + }); + + test("footer 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("/"); + + // Scroll to footer + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + // 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: /privacy policy/i }), + page.getByRole("link", { name: /terms of service/i }), + page.getByRole("link", { name: /follow us on bluesky/i }), + page.getByRole("link", { name: /follow us on gitlab/i }), + ]; + + for (const element of interactiveElements) { + await expect(element).toBeVisible(); + await expect(element).toBeEnabled(); + } + } + }); +}); diff --git a/tests/e2e/header.responsive.spec.js b/tests/e2e/header.responsive.spec.js index 0317ddf..c49ebd1 100644 --- a/tests/e2e/header.responsive.spec.js +++ b/tests/e2e/header.responsive.spec.js @@ -1,11 +1,15 @@ 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 }, + { name: "xs", width: 320, height: 700 }, + { name: "sm", width: 360, height: 700 }, + { name: "md", width: 480, height: 700 }, + { name: "lg", width: 640, height: 700 }, + { name: "xl", width: 768, height: 700 }, + { name: "2xl", width: 1024, height: 700 }, + { name: "3xl", width: 1280, height: 700 }, + { name: "4xl", width: 1440, height: 700 }, + { name: "full", width: 1920, height: 700 }, ]; for (const bp of breakpoints) { @@ -132,6 +136,89 @@ for (const bp of breakpoints) { }); } +// Visual regression tests +test.describe("Header visual regression", () => { + test("header visual consistency across breakpoints", async ({ page }) => { + // Test visual consistency at all breakpoints + for (const bp of breakpoints) { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.goto("/"); + + // Wait for layout to stabilize + await page.waitForTimeout(500); + + // Take a screenshot for visual regression testing + await expect(page.locator("header").first()).toHaveScreenshot( + `header-${bp.name}.png` + ); + } + }); + + test("header hover states visual consistency", async ({ page }) => { + // Test hover states at key breakpoints + const keyBreakpoints = [ + { name: "xs", width: 320, height: 700 }, + { name: "md", width: 768, height: 700 }, + { name: "xl", width: 1280, height: 700 }, + ]; + + for (const bp of keyBreakpoints) { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.goto("/"); + + // Test hover on navigation items + const useCasesLink = page.getByRole("link", { name: /use cases/i }); + await useCasesLink.hover(); + await page.waitForTimeout(200); + await expect(page.locator("header").first()).toHaveScreenshot( + `header-${bp.name}-hover-nav.png` + ); + + // Test hover on create rule button + const createRuleButton = page.getByRole("button", { + name: /create a new rule with avatar decoration/i, + }); + await createRuleButton.hover(); + await page.waitForTimeout(200); + await expect(page.locator("header").first()).toHaveScreenshot( + `header-${bp.name}-hover-button.png` + ); + } + }); + + test("header focus states visual consistency", async ({ page }) => { + // Test focus states at key breakpoints + const keyBreakpoints = [ + { name: "xs", width: 320, height: 700 }, + { name: "md", width: 768, height: 700 }, + { name: "xl", width: 1280, height: 700 }, + ]; + + for (const bp of keyBreakpoints) { + await page.setViewportSize({ width: bp.width, height: bp.height }); + await page.goto("/"); + + // Test focus on navigation items + const useCasesLink = page.getByRole("link", { name: /use cases/i }); + await useCasesLink.focus(); + await page.waitForTimeout(200); + await expect(page.locator("header").first()).toHaveScreenshot( + `header-${bp.name}-focus-nav.png` + ); + + // Test focus on create rule button + const createRuleButton = page.getByRole("button", { + name: /create a new rule with avatar decoration/i, + }); + await createRuleButton.focus(); + await page.waitForTimeout(200); + await expect(page.locator("header").first()).toHaveScreenshot( + `header-${bp.name}-focus-button.png` + ); + } + }); +}); + // Additional responsive behavior tests test.describe("Header responsive behavior", () => { test("header maintains proper layout across breakpoints", async ({ diff --git a/tests/visual/visual-regression.config.js b/tests/visual/visual-regression.config.js new file mode 100644 index 0000000..70890c0 --- /dev/null +++ b/tests/visual/visual-regression.config.js @@ -0,0 +1,215 @@ +/** + * Visual Regression Testing Configuration + * + * This file defines the configuration for visual regression testing across + * different breakpoints, components, and scenarios. + */ + +// Breakpoint definitions for responsive testing +export const breakpoints = { + // Mobile breakpoints + xs: { width: 320, height: 700, name: "Extra Small" }, + sm: { width: 360, height: 700, name: "Small" }, + md: { width: 480, height: 700, name: "Medium" }, + + // Tablet breakpoints + lg: { width: 640, height: 700, name: "Large" }, + xl: { width: 768, height: 700, name: "Extra Large" }, + + // Desktop breakpoints + "2xl": { width: 1024, height: 700, name: "2XL" }, + "3xl": { width: 1280, height: 700, name: "3XL" }, + "4xl": { width: 1440, height: 700, name: "4XL" }, + full: { width: 1920, height: 700, name: "Full HD" }, +}; + +// Key breakpoints for focused testing +export const keyBreakpoints = [ + breakpoints.xs, // Mobile + breakpoints.md, // Tablet + breakpoints.xl, // Desktop +]; + +// Visual testing scenarios +export const visualScenarios = { + // Component states + states: { + default: "Default state", + hover: "Hover state", + focus: "Focus state", + active: "Active/pressed state", + disabled: "Disabled state", + }, + + // Interactive states + interactions: { + hover: "Element hovered", + focus: "Element focused", + click: "Element clicked", + loading: "Loading state", + error: "Error state", + }, + + // Content variations + content: { + short: "Short content", + long: "Long content", + empty: "Empty state", + loading: "Loading content", + error: "Error content", + }, + + // Layout scenarios + layout: { + compact: "Compact layout", + spacious: "Spacious layout", + stacked: "Stacked layout", + grid: "Grid layout", + list: "List layout", + }, +}; + +// Chromatic configuration +export const chromaticConfig = { + // Viewports for Chromatic screenshots + viewports: Object.values(breakpoints).map((bp) => bp.width), + + // Delay for layout stabilization + delay: 200, + + // Modes for different themes + modes: { + light: {}, + dark: { + colorScheme: "dark", + }, + }, + + // Storybook viewport configuration + storybookViewports: Object.entries(breakpoints).reduce((acc, [key, bp]) => { + acc[key] = { + name: bp.name, + styles: { + width: `${bp.width}px`, + height: `${bp.height}px`, + }, + }; + return acc; + }, {}), +}; + +// Playwright visual testing configuration +export const playwrightVisualConfig = { + // Screenshot options + screenshot: { + fullPage: false, + type: "png", + quality: 90, + }, + + // Visual comparison options + visualComparison: { + threshold: 0.1, // 10% difference threshold + maxDiffPixels: 100, + maxDiffPixelRatio: 0.1, + }, + + // Test timeouts + timeouts: { + navigation: 30000, + action: 5000, + assertion: 10000, + }, +}; + +// Component-specific visual testing configurations +export const componentConfigs = { + Header: { + breakpoints: [breakpoints.xs, breakpoints.md, breakpoints.xl], + states: ["default", "hover", "focus"], + scenarios: ["navigation", "authentication", "responsive"], + }, + + Footer: { + breakpoints: [breakpoints.xs, breakpoints.md, breakpoints.xl], + states: ["default", "hover", "focus"], + scenarios: ["navigation", "social", "legal"], + }, + + Button: { + breakpoints: [breakpoints.sm, breakpoints.md, breakpoints.lg], + states: ["default", "hover", "focus", "active", "disabled"], + variants: ["default", "home"], + sizes: ["xsmall", "small", "medium", "large", "xlarge"], + }, + + Logo: { + breakpoints: [breakpoints.xs, breakpoints.md, breakpoints.xl], + states: ["default", "hover"], + variants: ["with-text", "icon-only"], + }, + + MenuBar: { + breakpoints: [breakpoints.xs, breakpoints.md, breakpoints.xl], + states: ["default", "hover", "focus"], + scenarios: ["navigation", "dropdown"], + }, +}; + +// Visual regression test patterns +export const testPatterns = { + // Basic component testing + basic: { + description: "Basic component rendering", + steps: [ + "Navigate to component", + "Wait for layout stabilization", + "Take screenshot", + ], + }, + + // Interactive state testing + interactive: { + description: "Interactive state testing", + steps: [ + "Navigate to component", + "Interact with element (hover/focus/click)", + "Wait for state change", + "Take screenshot", + ], + }, + + // Responsive testing + responsive: { + description: "Responsive behavior testing", + steps: [ + "Set viewport size", + "Navigate to component", + "Wait for layout stabilization", + "Take screenshot", + "Repeat for all breakpoints", + ], + }, + + // Content variation testing + contentVariation: { + description: "Content variation testing", + steps: [ + "Navigate to component with different content", + "Wait for layout stabilization", + "Take screenshot", + "Compare with baseline", + ], + }, +}; + +// Export all configurations +export default { + breakpoints, + keyBreakpoints, + visualScenarios, + chromaticConfig, + playwrightVisualConfig, + componentConfigs, + testPatterns, +};