feat: add locale smoke test to verify hreflang alternates and slug translations
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Failing after 1m12s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / ⚡ Performance & Accessibility (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Failing after 1m12s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / ⚡ Performance & Accessibility (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
191
scripts/check-locale.ts
Normal file
191
scripts/check-locale.ts
Normal file
@@ -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 <link rel="alternate" hreflang="..." href="..."> 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<string, string> = {
|
||||
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<string, string> = 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);
|
||||
});
|
||||
Reference in New Issue
Block a user