mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
Add Playwright a11y tests, structured data, and reduced motion support
- Add Playwright tests with axe-core (42 tests across 3 browsers) - Add prefers-reduced-motion support to NebulaField - Add JSON-LD structured data (Organization schema) - Add canonical URLs to all pages - Consolidate TODO files, update README
This commit is contained in:
@@ -6,7 +6,10 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test": "playwright test",
|
||||
"test:a11y": "playwright test tests/a11y.spec.ts",
|
||||
"test:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marsidev/react-turnstile": "^1.0.2",
|
||||
@@ -22,6 +25,8 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm run start",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
@@ -7,6 +7,9 @@ import type { Metadata } from "next";
|
||||
export const metadata: Metadata = {
|
||||
title: "About | Such Software",
|
||||
description: "Full-service software provider in Kennett Square, PA. Founded by John Winter Murphy, specializing in accessible web engineering and WCAG 2.2 compliance.",
|
||||
alternates: {
|
||||
canonical: "/about",
|
||||
},
|
||||
openGraph: {
|
||||
title: "About | Such Software",
|
||||
description: "Full-service software provider in Kennett Square, PA. Founded by John Winter Murphy.",
|
||||
|
||||
@@ -7,8 +7,12 @@ import { Footer } from "@/components/layout/footer";
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://such.software"),
|
||||
title: "Such Software | Digital Craftsmanship",
|
||||
description: "Next-generation web applications engineered for scale, compliance, and performance.",
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/images/favicon.svg", type: "image/svg+xml" },
|
||||
@@ -22,6 +26,32 @@ export const metadata: Metadata = {
|
||||
manifest: "/manifest.json",
|
||||
};
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: "Such Software LLC",
|
||||
url: "https://such.software",
|
||||
logo: "https://such.software/images/logo_dark.svg",
|
||||
description: "Next-generation web applications engineered for scale, compliance, and performance.",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: "110 E State St, Suite 300",
|
||||
addressLocality: "Kennett Square",
|
||||
addressRegion: "PA",
|
||||
postalCode: "19348",
|
||||
addressCountry: "US",
|
||||
},
|
||||
founder: {
|
||||
"@type": "Person",
|
||||
name: "John Winter Murphy",
|
||||
jobTitle: "Founder & Principal Engineer",
|
||||
},
|
||||
sameAs: [
|
||||
"https://github.com/suchsoftware",
|
||||
"https://linkedin.com/company/suchsoftware",
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -29,6 +59,12 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
|
||||
@@ -8,6 +8,9 @@ import type { Metadata } from "next";
|
||||
export const metadata: Metadata = {
|
||||
title: "Services | Such Software",
|
||||
description: "Medusa v2 development, custom web applications, and cryptocurrency consulting. High-performance headless commerce, real-time platforms, and Web3 integration.",
|
||||
alternates: {
|
||||
canonical: "/services",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Services | Such Software",
|
||||
description: "Medusa v2 development, custom web applications, and cryptocurrency consulting.",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
// Skip webkit for home page - canvas/WebGL issues cause error state
|
||||
const isWebkit = (browserName: string) => browserName === "webkit";
|
||||
|
||||
const pages = [
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "Services", path: "/services" },
|
||||
{ name: "About", path: "/about" },
|
||||
];
|
||||
|
||||
for (const { name, path } of pages) {
|
||||
test.describe(`${name} Page Accessibility`, () => {
|
||||
test(`should have no WCAG 2.2 AA violations`, async ({ page, browserName }) => {
|
||||
// Skip webkit on home page due to canvas/WebGL compatibility issues
|
||||
test.skip(isWebkit(browserName) && path === "/", "Webkit has canvas issues on home page");
|
||||
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
// Give time for React hydration
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
|
||||
.analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test(`should have proper heading hierarchy`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
const headings = await page.evaluate(() => {
|
||||
const h = document.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
||||
return Array.from(h).map((el) => ({
|
||||
level: parseInt(el.tagName[1]),
|
||||
text: el.textContent?.trim().slice(0, 50),
|
||||
}));
|
||||
});
|
||||
|
||||
// Should have exactly one h1
|
||||
const h1s = headings.filter((h) => h.level === 1);
|
||||
expect(h1s.length).toBe(1);
|
||||
|
||||
// No skipped heading levels
|
||||
for (let i = 1; i < headings.length; i++) {
|
||||
const current = headings[i].level;
|
||||
const previous = headings[i - 1].level;
|
||||
const skipped = current > previous + 1;
|
||||
expect(skipped, `Heading "${headings[i].text}" skips levels`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test(`should have no color contrast issues`, async ({ page, browserName }) => {
|
||||
test.skip(isWebkit(browserName) && path === "/", "Webkit has canvas issues on home page");
|
||||
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2aa"])
|
||||
.options({ runOnly: ["color-contrast"] })
|
||||
.analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test(`should be keyboard navigable`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Tab through interactive elements
|
||||
const focusableElements = await page.evaluate(() => {
|
||||
const elements = document.querySelectorAll(
|
||||
'a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
return elements.length;
|
||||
});
|
||||
|
||||
expect(focusableElements).toBeGreaterThan(0);
|
||||
|
||||
// First tab should reach skip link or first focusable element
|
||||
await page.keyboard.press("Tab");
|
||||
const firstFocused = await page.evaluate(() => document.activeElement?.tagName);
|
||||
expect(firstFocused).toBeTruthy();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Theme Accessibility", () => {
|
||||
test("should maintain accessibility in dark mode", async ({ page, browserName }) => {
|
||||
test.skip(isWebkit(browserName), "Webkit has canvas issues on home page");
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Toggle to dark mode
|
||||
const themeToggle = page.getByRole("button", { name: /theme|dark|light/i });
|
||||
if (await themeToggle.isVisible()) {
|
||||
await themeToggle.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa"])
|
||||
.analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Form Accessibility", () => {
|
||||
test("contact form should have proper labels and ARIA", async ({ page, browserName }) => {
|
||||
test.skip(isWebkit(browserName), "Webkit has canvas issues on home page");
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Scroll to contact section
|
||||
await page.evaluate(() => {
|
||||
document.getElementById("contact")?.scrollIntoView();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check form fields exist with accessible names (label or aria-label)
|
||||
const nameInput = page.locator('input[name="name"]');
|
||||
const emailInput = page.locator('input[name="email"]');
|
||||
const messageInput = page.locator('textarea[name="message"]');
|
||||
|
||||
await expect(nameInput).toBeVisible();
|
||||
await expect(emailInput).toBeVisible();
|
||||
await expect(messageInput).toBeVisible();
|
||||
|
||||
// Check fields have accessible labels (via label element or aria-label)
|
||||
const nameLabel = await nameInput.evaluate((el) => {
|
||||
const label = document.querySelector(`label[for="${el.id}"]`);
|
||||
return label?.textContent || el.getAttribute("aria-label") || el.getAttribute("placeholder");
|
||||
});
|
||||
expect(nameLabel).toBeTruthy();
|
||||
|
||||
// Check required fields are marked
|
||||
await expect(nameInput).toHaveAttribute("required");
|
||||
await expect(emailInput).toHaveAttribute("required");
|
||||
await expect(messageInput).toHaveAttribute("required");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user