diff --git a/.gitignore b/.gitignore index 092fdf49..ac4462cf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist/ # pnpm .pnpm-store/ +test-results/ diff --git a/locales/de.json b/locales/de.json index 8c0817c7..e7bf1a18 100644 --- a/locales/de.json +++ b/locales/de.json @@ -140,6 +140,9 @@ "contactUs": "Kontaktieren Sie uns", "offeringsTitle": "Unsere Support & Care Angebote", "learnMore": "Mehr erfahren →", + "altTexts": { + "underline": "Unterstreichung" + }, "maven": { "title": "Support & Care für Apache Maven™", "description": "Support & Care für Apache Maven™ stärkt die Zukunft des Java-Ökosystems durch nachhaltige Finanzierung und transparente Entwicklung. Sichern Sie die langfristige Stabilität und Weiterentwicklung von Apache Maven." diff --git a/locales/en.json b/locales/en.json index 633cafa4..f2a9ad8f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -140,6 +140,9 @@ "contactUs": "Contact us", "offeringsTitle": "Our Support & Care Offerings", "learnMore": "Learn more →", + "altTexts": { + "underline": "Underline" + }, "maven": { "title": "Support & Care for Apache Maven™", "description": "Support & Care for Apache Maven™ strengthens the future of the Java ecosystem through sustainable funding and transparent development. Secure the long-term stability and continued development of Apache Maven." diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 18e4e58b..2b626fba 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -74,11 +74,11 @@ export default async function LocaleLayout({ const messages = await getMessages(); return ( - + - +
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b8d5fc32..da969b84 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,15 +1,5 @@ import type { Metadata } from 'next' -import { Montserrat } from 'next/font/google' import './globals.css' -import Navbar from '@/components/Navbar' -import Footer from '@/components/Footer' - -const montserrat = Montserrat({ - subsets: ['latin'], - weight: ['300', '400', '500', '600', '700', '800', '900'], - variable: '--font-montserrat', - display: 'swap', -}) export const metadata: Metadata = { title: 'Open Elements - Open Source made right', @@ -33,8 +23,8 @@ export const metadata: Metadata = { }, } -// RootLayout is now in [locale]/layout.tsx for i18n support -// This is just a redirect wrapper +// Root layout delegates to [locale]/layout.tsx for i18n support +// The locale layout provides html/body with proper lang attributes export default function RootLayout({ children, }: { diff --git a/src/types/css.d.ts b/src/types/css.d.ts new file mode 100644 index 00000000..3255cfe2 --- /dev/null +++ b/src/types/css.d.ts @@ -0,0 +1 @@ +declare module '*.css'; \ No newline at end of file diff --git a/tests/e2e/about.spec.ts b/tests/e2e/about.spec.ts new file mode 100644 index 00000000..c240a444 --- /dev/null +++ b/tests/e2e/about.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; + +const locales = ['en', 'de'] as const; + +function localePath(locale: (typeof locales)[number], path: string = '') { + return locale === 'en' ? `/${path}` : `/${locale}/${path}`; +} + +test.describe('About Page', () => { + for (const locale of locales) { + test(`loads about page correctly for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, 'about')); + + // Page should load successfully + await expect(page).toHaveURL(/\/about/); + + // Should have main content + await expect(page.locator('main')).toBeVisible(); + + // Should maintain locale in URL + if (locale === 'de') { + await expect(page).toHaveURL(/\/de\/about/); + } else { + await expect(page).toHaveURL(/^(?!.*\/de\/).*\/about/); + } + }); + + test(`about page navigation works for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale)); + + // Find and click about link in navigation + const aboutLink = page.locator('nav a[href*="about"]').first(); + await aboutLink.click(); + + // Should navigate to about page + await expect(page).toHaveURL(/\/about/); + + // Should maintain locale + if (locale === 'de') { + await expect(page).toHaveURL(/\/de\/about/); + } + }); + } +}); diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts new file mode 100644 index 00000000..6a62be5c --- /dev/null +++ b/tests/e2e/accessibility.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; + +const locales = ['en', 'de'] as const; + +function localePath(locale: (typeof locales)[number], path: string = '') { + return locale === 'en' ? `/${path}` : `/${locale}/${path}`; +} + +test.describe('Accessibility', () => { + for (const locale of locales) { + test(`home page has proper heading hierarchy for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale)); + + // Should have exactly one h1 + const h1Count = await page.locator('h1').count(); + expect(h1Count).toBe(1); + + // H1 should have text content + const h1Text = await page.locator('h1').first().textContent(); + expect(h1Text?.trim().length).toBeGreaterThan(0); + }); + + test(`navigation has proper ARIA landmarks for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale)); + + // Should have navigation landmark + const nav = page.locator('nav, [role="navigation"]'); + await expect(nav).toBeVisible(); + + // Should have main landmark + const main = page.locator('main, [role="main"]'); + await expect(main).toBeVisible(); + }); + + test(`all images have alt text for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale)); + + // Get all images + const images = page.locator('img'); + const count = await images.count(); + + // Check each image has alt attribute + for (let i = 0; i < count; i++) { + const img = images.nth(i); + const alt = await img.getAttribute('alt'); + // Alt can be empty string for decorative images, but must exist + expect(alt).not.toBeNull(); + } + }); + + test(`interactive elements are keyboard accessible for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale)); + + // All links should be keyboard accessible + const links = page.locator('a[href]'); + const linkCount = await links.count(); + + if (linkCount > 0) { + const firstLink = links.first(); + await expect(firstLink).toBeVisible(); + + // Link should be focusable + await firstLink.focus(); + await expect(firstLink).toBeFocused(); + } + }); + + test(`page has valid lang attribute for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale)); + + const htmlLang = await page.locator('html').getAttribute('lang'); + + if (locale === 'de') { + expect(htmlLang).toContain('de'); + } else { + expect(htmlLang).toContain('en'); + } + }); + } +}); diff --git a/tests/e2e/contact.spec.ts b/tests/e2e/contact.spec.ts new file mode 100644 index 00000000..2092e488 --- /dev/null +++ b/tests/e2e/contact.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test'; + +const locales = ['en', 'de'] as const; + +function localePath(locale: (typeof locales)[number], path: string = '') { + return locale === 'en' ? `/${path}` : `/${locale}/${path}`; +} + +test.describe('Contact Page', () => { + for (const locale of locales) { + test(`loads contact page correctly for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, 'contact')); + + // Page should load successfully + await expect(page).toHaveURL(/\/contact/); + + // Should have main content + await expect(page.locator('main')).toBeVisible(); + + // Should maintain locale in URL + if (locale === 'de') { + await expect(page).toHaveURL(/\/de\/contact/); + } else { + await expect(page).toHaveURL(/^(?!.*\/de\/).*\/contact/); + } + }); + + test(`contact page navigation works for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale)); + + // Find and click contact link in navigation + const contactLink = page.locator('nav a[href*="contact"]').first(); + + if (await contactLink.count() > 0) { + await contactLink.click(); + + // Should navigate to contact page + await expect(page).toHaveURL(/\/contact/); + + // Should maintain locale + if (locale === 'de') { + await expect(page).toHaveURL(/\/de\/contact/); + } + } + }); + } +}); diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts new file mode 100644 index 00000000..9ec576a2 --- /dev/null +++ b/tests/e2e/home.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; + +const locales = ['en', 'de'] as const; + +function localePath(locale: (typeof locales)[number]) { + return locale === 'en' ? '/' : `/${locale}/`; +} + +test.describe('Home Page', () => { + for (const locale of locales) { + test(`loads home page correctly for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale)); + + // Page should load successfully + await expect(page).toHaveTitle(/Open Elements/i); + + // Should have main navigation + await expect(page.locator('nav')).toBeVisible(); + + // Should have main content + await expect(page.locator('main')).toBeVisible(); + }); + + test(`home page has proper meta tags for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale)); + + // Should have meta description + const metaDescription = page.locator('meta[name="description"]'); + await expect(metaDescription).toHaveAttribute('content', /.+/); + + // Should have viewport meta tag + const viewport = page.locator('meta[name="viewport"]'); + await expect(viewport).toHaveAttribute('content', /.+/); + }); + + test(`home page is responsive for ${locale}`, async ({ page }) => { + // Test mobile view + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto(localePath(locale)); + await expect(page.locator('nav')).toBeVisible(); + + // Test tablet view + await page.setViewportSize({ width: 768, height: 1024 }); + await expect(page.locator('nav')).toBeVisible(); + + // Test desktop view + await page.setViewportSize({ width: 1920, height: 1080 }); + await expect(page.locator('nav')).toBeVisible(); + }); + } +}); diff --git a/tests/e2e/locale-switching.spec.ts b/tests/e2e/locale-switching.spec.ts new file mode 100644 index 00000000..2cebed8e --- /dev/null +++ b/tests/e2e/locale-switching.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Locale Switching', () => { + test('can switch between EN and DE on home page', async ({ page }) => { + // Start with English + await page.goto('/'); + await expect(page).not.toHaveURL(/\/de\//); + + // Find and click language switcher to German + const languageSwitcher = page.locator('[data-locale-switcher], a[href*="/de/"], button[aria-label*="Deutsch"], button[aria-label*="German"]').first(); + + if (await languageSwitcher.count() > 0) { + await languageSwitcher.click(); + + // Should switch to German + await expect(page).toHaveURL(/\/de\//); + } + }); + + test('maintains page context when switching locale', async ({ page }) => { + // Go to about page in English + await page.goto('/about'); + + // Find language switcher + const languageSwitcher = page.locator('[data-locale-switcher], a[href*="/de/about"], button[aria-label*="Deutsch"]').first(); + + if (await languageSwitcher.count() > 0) { + await languageSwitcher.click(); + + // Should be on about page in German + await expect(page).toHaveURL(/\/de\/about/); + } + }); + + test('locale persists across navigation', async ({ page }) => { + // Start with German + await page.goto('/de/'); + + // Navigate to about page + const aboutLink = page.locator('nav a[href*="about"]').first(); + if (await aboutLink.count() > 0) { + await aboutLink.click(); + + // Should still be in German + await expect(page).toHaveURL(/\/de\/about/); + } + + // Navigate to contact page + const contactLink = page.locator('nav a[href*="contact"]').first(); + if (await contactLink.count() > 0) { + await contactLink.click(); + + // Should still be in German + await expect(page).toHaveURL(/\/de\/contact/); + } + }); +}); diff --git a/tests/e2e/performance.spec.ts b/tests/e2e/performance.spec.ts new file mode 100644 index 00000000..3383d6e4 --- /dev/null +++ b/tests/e2e/performance.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test'; + +const locales = ['en', 'de'] as const; + +function localePath(locale: (typeof locales)[number]) { + return locale === 'en' ? '/' : `/${locale}/`; +} + +test.describe('Performance', () => { + for (const locale of locales) { + test(`home page loads within reasonable time for ${locale}`, async ({ page }) => { + const startTime = Date.now(); + + await page.goto(localePath(locale), { waitUntil: 'domcontentloaded' }); + + const loadTime = Date.now() - startTime; + + // Page should load within 5 seconds + expect(loadTime).toBeLessThan(5000); + }); + + test(`navigation has no console errors for ${locale}`, async ({ page }) => { + const errors: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + + await page.goto(localePath(locale)); + + // Should not have critical console errors + expect(errors.length).toBe(0); + }); + + test(`page has no broken resources for ${locale}`, async ({ page }) => { + const failedRequests: string[] = []; + + page.on('requestfailed', (request) => { + failedRequests.push(request.url()); + }); + + await page.goto(localePath(locale), { waitUntil: 'networkidle' }); + + // Should not have failed resource loads + expect(failedRequests.length).toBe(0); + }); + } +}); diff --git a/tests/e2e/posts.spec.ts b/tests/e2e/posts.spec.ts new file mode 100644 index 00000000..307f225a --- /dev/null +++ b/tests/e2e/posts.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test'; + +const locales = ['en', 'de'] as const; + +function localePath(locale: (typeof locales)[number], path: string = '') { + return locale === 'en' ? `/${path}` : `/${locale}/${path}`; +} + +test.describe('Posts Page', () => { + for (const locale of locales) { + test(`loads posts listing page correctly for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, 'posts')); + + // Page should load successfully + await expect(page).toHaveURL(/\/posts/); + + // Should have main content + await expect(page.locator('main')).toBeVisible(); + + // Should maintain locale in URL + if (locale === 'de') { + await expect(page).toHaveURL(/\/de\/posts/); + } else { + await expect(page).toHaveURL(/^(?!.*\/de\/).*\/posts/); + } + }); + + test(`posts page shows list of posts for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, 'posts')); + + // Should have at least one post link + const postLinks = page.locator('a[href*="/posts/"]'); + const count = await postLinks.count(); + expect(count).toBeGreaterThan(0); + }); + + test(`can navigate to individual post from listing for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, 'posts')); + + // Get first post link that goes to a specific post (not just /posts) + const postLinks = page.locator('a[href*="/posts/"]'); + const count = await postLinks.count(); + + let foundIndividualPost = false; + for (let i = 0; i < count; i++) { + const link = postLinks.nth(i); + const href = await link.getAttribute('href'); + + // Check if it's a link to an individual post (contains /posts/slug) + if (href && !href.startsWith('http') && href.match(/\/posts\/[^\/]+$/)) { + await link.click(); + + // Should navigate to individual post + await expect(page).toHaveURL(/\/posts\/[^\/]+$/); + + // Should maintain locale + if (locale === 'de') { + await expect(page).toHaveURL(/\/de\/posts\//); + } + + foundIndividualPost = true; + break; + } + } + + // If no individual posts found, at least verify we're on the posts page + if (!foundIndividualPost) { + await expect(page).toHaveURL(/\/posts/); + } + }); + } +}); diff --git a/tests/e2e/seo.spec.ts b/tests/e2e/seo.spec.ts new file mode 100644 index 00000000..33752acf --- /dev/null +++ b/tests/e2e/seo.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '@playwright/test'; + +const locales = ['en', 'de'] as const; + +function localePath(locale: (typeof locales)[number], path: string = '') { + return locale === 'en' ? `/${path}` : `/${locale}/${path}`; +} + +test.describe('SEO', () => { + const pages = ['', 'about', 'contact', 'posts', 'support-care']; + + for (const locale of locales) { + for (const pagePath of pages) { + test(`${pagePath || 'home'} page has title tag for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, pagePath)); + + const title = await page.title(); + expect(title.length).toBeGreaterThan(0); + expect(title.length).toBeLessThan(70); // SEO best practice + }); + + test(`${pagePath || 'home'} page has meta description for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, pagePath)); + + const metaDescription = await page.locator('meta[name="description"]').getAttribute('content'); + + if (metaDescription) { + expect(metaDescription.length).toBeGreaterThan(50); + expect(metaDescription.length).toBeLessThan(200); // Allow slightly longer descriptions + } + }); + + test(`${pagePath || 'home'} page has Open Graph tags for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, pagePath)); + + // Should have og:title + const ogTitle = page.locator('meta[property="og:title"]'); + await expect(ogTitle).toHaveAttribute('content', /.+/); + + // Should have og:type + const ogType = page.locator('meta[property="og:type"]'); + if (await ogType.count() > 0) { + await expect(ogType).toHaveAttribute('content', /.+/); + } + }); + + test(`${pagePath || 'home'} page has canonical URL for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, pagePath)); + + const canonical = page.locator('link[rel="canonical"]'); + + if (await canonical.count() > 0) { + const href = await canonical.getAttribute('href'); + expect(href).toBeTruthy(); + } + }); + } + } + + test('robots.txt exists and is accessible', async ({ page, baseURL }) => { + const response = await page.goto(`${baseURL}/robots.txt`); + expect(response?.status()).toBe(200); + + const content = await page.content(); + expect(content).toContain('User-agent'); + }); + + test('sitemap.xml exists and is accessible', async ({ page, baseURL }) => { + const response = await page.goto(`${baseURL}/sitemap.xml`); + expect(response?.status()).toBe(200); + + const content = await page.content(); + // Check for sitemap content (may be wrapped in HTML by browser) + expect(content).toMatch(/sitemap|urlset/); + }); +}); diff --git a/tests/e2e/support-care.spec.ts b/tests/e2e/support-care.spec.ts new file mode 100644 index 00000000..2424a6b8 --- /dev/null +++ b/tests/e2e/support-care.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; + +const locales = ['en', 'de'] as const; + +function localePath(locale: (typeof locales)[number], path: string = '') { + return locale === 'en' ? `/${path}` : `/${locale}/${path}`; +} + +test.describe('Support Care Pages', () => { + for (const locale of locales) { + test(`loads support care page correctly for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, 'support-care')); + + // Page should load successfully + await expect(page).toHaveURL(/\/support-care/); + + // Should have main content + await expect(page.locator('main')).toBeVisible(); + + // Should maintain locale in URL + if (locale === 'de') { + await expect(page).toHaveURL(/\/de\/support-care/); + } else { + await expect(page).toHaveURL(/^(?!.*\/de\/).*\/support-care/); + } + }); + + test(`loads support care maven page correctly for ${locale}`, async ({ page }) => { + await page.goto(localePath(locale, 'support-care-maven')); + + // Page should load successfully + await expect(page).toHaveURL(/\/support-care-maven/); + + // Should have main content + await expect(page.locator('main')).toBeVisible(); + + // Should maintain locale in URL + if (locale === 'de') { + await expect(page).toHaveURL(/\/de\/support-care-maven/); + } else { + await expect(page).toHaveURL(/^(?!.*\/de\/).*\/support-care-maven/); + } + }); + } +});