From 2125131335d510c4cd31fc7ac1666294cf3660f2 Mon Sep 17 00:00:00 2001 From: ColdByDefault Date: Sat, 2 May 2026 15:40:34 +0200 Subject: [PATCH 01/14] feat: integrate Playwright for end-to-end testing and update .gitignore --- .gitignore | 3 ++ package-lock.json | 63 ++++++++++++++++++++++++++++++++++ package.json | 5 ++- playwright.config.ts | 34 ++++++++++++++++++ proxy.ts | 8 ++--- tests/e2e/live-tools.spec.ts | 58 +++++++++++++++++++++++++++++++ tests/e2e/locale.spec.ts | 54 +++++++++++++++++++++++++++++ tests/e2e/public-pages.spec.ts | 49 ++++++++++++++++++++++++++ 8 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/live-tools.spec.ts create mode 100644 tests/e2e/locale.spec.ts create mode 100644 tests/e2e/public-pages.spec.ts diff --git a/.gitignore b/.gitignore index dac5795..22029a9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ !.yarn/versions # testing /coverage +/test-results +/playwright-report +/blob-report # next.js /.next/ /out/ diff --git a/package-lock.json b/package-lock.json index d5d504b..2e45ed0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "devDependencies": { "@eslint/js": "^9.34.0", "@next/eslint-plugin-next": "^15.5.9", + "@playwright/test": "^1.59.1", "@types/node": "^24", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -2216,6 +2217,22 @@ "node": ">=0.10" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", @@ -10186,6 +10203,52 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/po-parser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", diff --git a/package.json b/package.json index 3be182a..37481b4 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "typecheck": "tsc --noEmit", "lint": "eslint .", "lint:fix": "eslint . --fix", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "test-dep": "npx npm-check-updates --interactive", "postinstall": "prisma generate", "db:migrate": "prisma migrate deploy", @@ -53,6 +55,7 @@ "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.13", @@ -62,7 +65,6 @@ "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/postcss": "^4", "@vercel/analytics": "^1.5.0", @@ -93,6 +95,7 @@ "devDependencies": { "@eslint/js": "^9.34.0", "@next/eslint-plugin-next": "^15.5.9", + "@playwright/test": "^1.59.1", "@types/node": "^24", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..bd919f9 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000"; +const shouldUseExternalServer = Boolean(process.env.PLAYWRIGHT_BASE_URL); + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + ...(shouldUseExternalServer + ? {} + : { + webServer: { + command: "npm run dev", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + }), +}); diff --git a/proxy.ts b/proxy.ts index b2b6ea6..ed94ef7 100644 --- a/proxy.ts +++ b/proxy.ts @@ -230,10 +230,6 @@ export function proxy(request: NextRequest) { failedAttempts.delete(clientIP); } - // Handle automatic locale detection for first-time visitors - const response = handleLocaleDetection(request); - if (response) return response; - // Define valid routes to avoid redirecting legitimate paths const validRoutes = [ "/", @@ -286,6 +282,10 @@ export function proxy(request: NextRequest) { } } + // Handle automatic locale detection for first-time visitors + const response = handleLocaleDetection(request); + if (response) return response; + return NextResponse.next(); } diff --git a/tests/e2e/live-tools.spec.ts b/tests/e2e/live-tools.spec.ts new file mode 100644 index 0000000..16c78e5 --- /dev/null +++ b/tests/e2e/live-tools.spec.ts @@ -0,0 +1,58 @@ +import { expect, test, type BrowserContext } from "@playwright/test"; +import enMessages from "../../messages/en.json"; + +const localeCookieName = "PORTFOLIOVERSIONLATEST_LOCALE"; +const defaultBaseURL = "http://localhost:3000"; + +async function setEnglishLocale( + context: BrowserContext, + baseURL: string | undefined, +) { + await context.addCookies([ + { + name: localeCookieName, + value: "en", + url: new URL("/", baseURL ?? defaultBaseURL).toString(), + }, + ]); +} + +test.describe("live tools", () => { + test.beforeEach(async ({ context }, testInfo) => { + await setEnglishLocale(context, testInfo.project.use.baseURL); + }); + + test("updates the ROI calculator when inputs change", async ({ page }) => { + await page.goto("/rio-calculator"); + + await expect( + page.getByRole("heading", { name: enMessages.LiveTools.rio.title }), + ).toBeVisible(); + + const hoursInput = page.getByLabel( + enMessages.LiveTools.rio.inputs.hoursPerWeek, + ); + + await hoursInput.click(); + await hoursInput.press("Control+A"); + await hoursInput.pressSequentially("10"); + + await expect(page.getByText(/23\.400/)).toBeVisible(); + }); + + test("enables email analysis only after text is entered", async ({ page }) => { + await page.goto("/polite-email"); + + await expect(page.getByText("Email Assistant", { exact: true })).toBeVisible(); + + const submitButton = page.getByRole("button", { + name: "Analyze & Generate Replies", + }); + + await expect(submitButton).toBeDisabled(); + await page + .getByLabel("Email to Analyze") + .fill("Hello, can you send the invoice when you have a moment?"); + await expect(submitButton).toBeEnabled(); + }); +}); diff --git a/tests/e2e/locale.spec.ts b/tests/e2e/locale.spec.ts new file mode 100644 index 0000000..4f65006 --- /dev/null +++ b/tests/e2e/locale.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; + +const localeCookieName = "PORTFOLIOVERSIONLATEST_LOCALE"; +const browserLanguageCookieName = "PORTFOLIOVERSIONLATEST_BROWSER_LANG"; +const defaultBaseURL = "http://localhost:3000"; + +test.describe("locale proxy behavior", () => { + test("sets locale cookies from the Accept-Language header", async ({ + browser, + }, testInfo) => { + const baseURL = testInfo.project.use.baseURL ?? defaultBaseURL; + const context = await browser.newContext({ + baseURL, + extraHTTPHeaders: { + "Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8", + }, + locale: "fr-FR", + }); + + try { + const page = await context.newPage(); + + await page.goto("/"); + await expect + .poll(async () => { + const cookies = await context.cookies(baseURL); + return cookies.find((cookie) => cookie.name === localeCookieName) + ?.value; + }) + .toBe("fr"); + await expect + .poll(async () => { + const cookies = await context.cookies(baseURL); + return cookies.find( + (cookie) => cookie.name === browserLanguageCookieName, + )?.value; + }) + .toBe("fr"); + + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("lang", "fr"); + } finally { + await context.close(); + } + }); + + test("redirects legacy German locale-prefixed routes", async ({ page }) => { + await page.goto("/de/about"); + + await expect + .poll(() => new URL(page.url()).pathname) + .toBe("/about"); + }); +}); diff --git a/tests/e2e/public-pages.spec.ts b/tests/e2e/public-pages.spec.ts new file mode 100644 index 0000000..0d82a6e --- /dev/null +++ b/tests/e2e/public-pages.spec.ts @@ -0,0 +1,49 @@ +import { expect, test, type BrowserContext } from "@playwright/test"; + +const localeCookieName = "PORTFOLIOVERSIONLATEST_LOCALE"; +const defaultBaseURL = "http://localhost:3000"; +const publicRoutes = [ + "/", + "/about", + "/about-portfolio", + "/services", + "/projects", + "/blog", + "/rio-calculator", + "/polite-email", + "/impressum", + "/privacy", +] as const; + +async function setLocaleCookie( + context: BrowserContext, + baseURL: string | undefined, + locale: string, +) { + await context.addCookies([ + { + name: localeCookieName, + value: locale, + url: new URL("/", baseURL ?? defaultBaseURL).toString(), + }, + ]); +} + +test.describe("public pages", () => { + test.beforeEach(async ({ context }, testInfo) => { + await setLocaleCookie(context, testInfo.project.use.baseURL, "en"); + }); + + for (const route of publicRoutes) { + test(`loads ${route}`, async ({ page }) => { + const response = await page.goto(route); + + expect(response, `${route} should return a response`).not.toBeNull(); + expect(response?.ok(), `${route} should load successfully`).toBe(true); + await expect(page.locator("#main-content")).toBeVisible(); + await expect(page.locator("body")).not.toContainText( + /Application error|This page could not be found/i, + ); + }); + } +}); From c5e404bf754e67ccbdcc8fd1958fd7364e2410ca Mon Sep 17 00:00:00 2001 From: ColdByDefault Date: Sat, 2 May 2026 16:04:17 +0200 Subject: [PATCH 02/14] feat: enhance accessibility by adding aria-hidden attributes to icons and labels --- components/blog/dashboard/Authentication.tsx | 6 +++++- components/projects/ProjectsFilter.tsx | 8 +++++++- components/projects/ProjectsHomeShowcase.tsx | 15 ++++++++------ components/services/PackageCard.tsx | 9 ++++++--- components/speed-insight/SpeedInsight.tsx | 10 ++++++++-- components/use-cases/implementation-areas.tsx | 2 +- components/use-cases/project-links.tsx | 4 ++-- components/use-cases/tech-stack-grid.tsx | 2 +- package-lock.json | 20 ++++++++++++++++--- package.json | 1 + 10 files changed, 57 insertions(+), 20 deletions(-) diff --git a/components/blog/dashboard/Authentication.tsx b/components/blog/dashboard/Authentication.tsx index ba31dfb..30ba1d4 100644 --- a/components/blog/dashboard/Authentication.tsx +++ b/components/blog/dashboard/Authentication.tsx @@ -52,10 +52,14 @@ export function Authentication({ )}
-
@@ -154,7 +154,10 @@ export function ProjectsHomeShowcase({ className }: ProjectsHomeShowcaseProps) {
{/* Number + category */}
- +
@@ -209,7 +212,7 @@ export function ProjectsHomeShowcase({ className }: ProjectsHomeShowcaseProps) { aria-label={`${project.title} GitHub`} className="text-muted-foreground hover:text-foreground transition-colors duration-200" > - +
@@ -247,7 +250,7 @@ export function ProjectsHomeShowcase({ className }: ProjectsHomeShowcaseProps) { className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors duration-200 border border-border/50 hover:border-muted-foreground/50 rounded-lg px-5 py-2.5" > {t("viewAllProjects")} - +
diff --git a/components/services/PackageCard.tsx b/components/services/PackageCard.tsx index 5b347ef..3b8fbd5 100644 --- a/components/services/PackageCard.tsx +++ b/components/services/PackageCard.tsx @@ -45,7 +45,7 @@ export function PackageCard({ pkg, variant = "detailed" }: PackageCardProps) {
{IconComponent && (
- +
)}
@@ -67,7 +67,7 @@ export function PackageCard({ pkg, variant = "detailed" }: PackageCardProps) {

{t(pkg.pricingKey)}

- +
@@ -80,7 +80,10 @@ export function PackageCard({ pkg, variant = "detailed" }: PackageCardProps) { : pkg.features ).map((feature, index) => (
  • - +
  • ))} diff --git a/components/speed-insight/SpeedInsight.tsx b/components/speed-insight/SpeedInsight.tsx index 85aedf7..8de2c84 100644 --- a/components/speed-insight/SpeedInsight.tsx +++ b/components/speed-insight/SpeedInsight.tsx @@ -119,7 +119,10 @@ export default function SpeedInsight({ className }: { className?: string }) {
    - +
    @@ -241,7 +244,10 @@ export default function SpeedInsight({ className }: { className?: string }) { {/* Footer */}
    - +
    {desktop?.fetchedAt && ( diff --git a/components/use-cases/implementation-areas.tsx b/components/use-cases/implementation-areas.tsx index 8891853..3310d98 100644 --- a/components/use-cases/implementation-areas.tsx +++ b/components/use-cases/implementation-areas.tsx @@ -27,7 +27,7 @@ export function ImplementationAreas({ return (

    - +

    diff --git a/components/use-cases/project-links.tsx b/components/use-cases/project-links.tsx index 01f11ee..48543f8 100644 --- a/components/use-cases/project-links.tsx +++ b/components/use-cases/project-links.tsx @@ -25,7 +25,7 @@ export function ProjectLinks({ demoLink, githubLink }: ProjectLinksProps) { {demoLink && ( @@ -37,7 +37,7 @@ export function ProjectLinks({ demoLink, githubLink }: ProjectLinksProps) { className="flex-1 min-w-35 bg-transparent" > - + diff --git a/components/use-cases/tech-stack-grid.tsx b/components/use-cases/tech-stack-grid.tsx index 6a9010f..b307ceb 100644 --- a/components/use-cases/tech-stack-grid.tsx +++ b/components/use-cases/tech-stack-grid.tsx @@ -107,7 +107,7 @@ export function TechStackGrid({ techStack }: TechStackGridProps) { variant="secondary" className="flex items-center gap-2 px-3 py-1.5" > - +