From a168f96f3c48911be2bc9ca0296cadb01b880d0d Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 25 Feb 2026 01:41:31 +0100 Subject: [PATCH] feat: add locale smoke test to verify hreflang alternates and slug translations --- .gitea/workflows/deploy.yml | 5 + package.json | 1 + scripts/check-locale.ts | 191 ++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 scripts/check-locale.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fb2b73e0..5d59100c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -439,6 +439,11 @@ jobs: NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} run: pnpm run check:http + - name: 🌐 Locale & Language Switcher Validation + env: + NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} + GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} + run: pnpm run check:locale # ── Quality Gates (informational, don't block pipeline) ─────────────── - name: 🌐 HTML DOM Validation diff --git a/package.json b/package.json index 7965ba04..6e9eaaab 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "check:wcag": "tsx ./scripts/wcag-sitemap.ts", "check:html": "tsx ./scripts/check-html.ts", "check:http": "tsx ./scripts/check-http.ts", + "check:locale": "tsx ./scripts/check-locale.ts", "check:spell": "cspell \"content/**/*.{md,mdx}\" \"app/**/*.tsx\" \"components/**/*.tsx\"", "check:security": "tsx ./scripts/check-security.ts", "check:links": "bash ./scripts/check-links.sh", diff --git a/scripts/check-locale.ts b/scripts/check-locale.ts new file mode 100644 index 00000000..26eeab7a --- /dev/null +++ b/scripts/check-locale.ts @@ -0,0 +1,191 @@ +import axios from 'axios'; +import * as cheerio from 'cheerio'; + +/** + * Locale & Language Switcher Smoke Test + * + * For every URL in the sitemap: + * 1. Fetches the page HTML + * 2. Extracts tags + * 3. Verifies each alternate URL uses correctly translated slugs + * 4. Verifies each alternate URL returns HTTP 200 + */ + +const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; +const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; + +// Expected slug translations: German key → English value +const SLUG_MAP: Record = { + produkte: 'products', + kontakt: 'contact', + niederspannungskabel: 'low-voltage-cables', + mittelspannungskabel: 'medium-voltage-cables', + hochspannungskabel: 'high-voltage-cables', + solarkabel: 'solar-cables', + impressum: 'legal-notice', + datenschutz: 'privacy-policy', + agbs: 'terms', +}; + +// Reverse map: English → German +const REVERSE_SLUG_MAP: Record = Object.fromEntries( + Object.entries(SLUG_MAP).map(([de, en]) => [en, de]), +); + +const headers = { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` }; + +function getExpectedTranslation( + sourcePath: string, + sourceLocale: string, + targetLocale: string, +): string { + const segments = sourcePath.split('/').filter(Boolean); + // First segment is locale + segments[0] = targetLocale; + + const map = sourceLocale === 'de' ? SLUG_MAP : REVERSE_SLUG_MAP; + + return ( + '/' + + segments + .map((seg, i) => { + if (i === 0) return seg; // locale + return map[seg] || seg; // translate or keep (product names like n2x2y stay the same) + }) + .join('/') + ); +} + +async function main() { + console.log(`\n🌐 Starting Locale Smoke Test for: ${targetUrl}\n`); + + // 1. Fetch sitemap + const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`; + console.log(`šŸ“„ Fetching sitemap from ${sitemapUrl}...`); + const sitemapRes = await axios.get(sitemapUrl, { headers, validateStatus: (s) => s < 400 }); + const $sitemap = cheerio.load(sitemapRes.data, { xmlMode: true }); + + let urls = $sitemap('url loc') + .map((_i, el) => $sitemap(el).text()) + .get(); + + const urlPattern = /https?:\/\/[^/]+/; + urls = [...new Set(urls)] + .filter((u) => u.startsWith('http')) + .map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, ''))) + .sort(); + + console.log(`āœ… Found ${urls.length} URLs in sitemap.\n`); + + let totalChecked = 0; + let totalPassed = 0; + let totalFailed = 0; + const failures: string[] = []; + + for (const url of urls) { + const path = new URL(url).pathname; + const locale = path.split('/')[1]; + if (!locale || !['de', 'en'].includes(locale)) continue; + + try { + const res = await axios.get(url, { headers, validateStatus: null }); + if (res.status >= 400) continue; // Skip pages that are already broken (check-http catches those) + + const $ = cheerio.load(res.data); + + // Extract hreflang alternate links + const alternates: { hreflang: string; href: string }[] = []; + $('link[rel="alternate"][hreflang]').each((_i, el) => { + const hreflang = $(el).attr('hreflang') || ''; + let href = $(el).attr('href') || ''; + if (href && hreflang && hreflang !== 'x-default') { + href = href.replace(urlPattern, targetUrl.replace(/\/$/, '')); + alternates.push({ hreflang, href }); + } + }); + + if (alternates.length === 0) { + // Some pages may not have alternates, that's OK + continue; + } + + totalChecked++; + + // Validate each alternate + let pageOk = true; + + for (const alt of alternates) { + if (alt.hreflang === locale) continue; // Same locale, skip + + // 1. Check slug translation is correct + const expectedPath = getExpectedTranslation(path, locale, alt.hreflang); + const actualPath = new URL(alt.href).pathname; + + if (actualPath !== expectedPath) { + console.error( + `āŒ SLUG MISMATCH: ${path} → hreflang="${alt.hreflang}" expected ${expectedPath} but got ${actualPath}`, + ); + failures.push( + `Slug mismatch: ${path} → ${alt.hreflang}: expected ${expectedPath}, got ${actualPath}`, + ); + pageOk = false; + continue; + } + + // 2. Check alternate URL returns 200 + try { + const altRes = await axios.get(alt.href, { + headers, + validateStatus: null, + maxRedirects: 5, + }); + if (altRes.status >= 400) { + console.error(`āŒ BROKEN ALTERNATE: ${path} → ${alt.href} returned ${altRes.status}`); + failures.push(`Broken alternate: ${path} → ${alt.href} (${altRes.status})`); + pageOk = false; + } + } catch (err: any) { + console.error(`āŒ NETWORK ERROR: ${path} → ${alt.href}: ${err.message}`); + failures.push(`Network error: ${path} → ${alt.href}: ${err.message}`); + pageOk = false; + } + } + + if (pageOk) { + console.log( + `āœ… ${path} — alternates OK (${alternates + .map((a) => a.hreflang) + .filter((h) => h !== locale) + .join(', ')})`, + ); + totalPassed++; + } else { + totalFailed++; + } + } catch (err: any) { + console.error(`āŒ NETWORK ERROR fetching ${url}: ${err.message}`); + totalFailed++; + } + } + + console.log(`\n${'─'.repeat(60)}`); + console.log(`šŸ“Š Locale Smoke Test Results:`); + console.log(` Pages checked: ${totalChecked}`); + console.log(` Passed: ${totalPassed}`); + console.log(` Failed: ${totalFailed}`); + + if (failures.length > 0) { + console.log(`\nāŒ Failures:`); + failures.forEach((f) => console.log(` • ${f}`)); + console.log(`\nāŒ Locale Smoke Test FAILED.`); + process.exit(1); + } else { + console.log(`\n✨ All locale alternates are correctly translated and reachable!`); + process.exit(0); + } +} + +main().catch((err) => { + console.error(`\nāŒ Critical error:`, err.message); + process.exit(1); +});