From b7438f271802e0c050bd56aee38f55ad2d628419 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 27 Feb 2026 19:24:53 +0100 Subject: [PATCH] ci: add missing core smoke test scripts and dependencies --- package.json | 8 +- scripts/check-apis.ts | 133 +++++++++++++++++++++++++++ scripts/check-http.ts | 83 +++++++++++++++++ scripts/check-locale.ts | 197 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 scripts/check-apis.ts create mode 100644 scripts/check-http.ts create mode 100644 scripts/check-locale.ts diff --git a/package.json b/package.json index 3dcba51..e5f0d3b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "prepare": "husky", "generate:types": "payload generate:types", "generate:importmap": "payload generate:importmap", - "pagespeed:test": "mintel pagespeed test" + "pagespeed:test": "mintel pagespeed test", + "check:http": "tsx ./scripts/check-http.ts", + "check:apis": "tsx ./scripts/check-apis.ts", + "check:locale": "tsx ./scripts/check-locale.ts" }, "keywords": [], "author": "", @@ -62,6 +65,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.23", + "axios": "^1.13.5", + "cheerio": "^1.2.0", "eslint": "^8.57.1", "eslint-config-next": "15.1.6", "happy-dom": "^20.6.1", @@ -72,6 +77,7 @@ "postcss": "^8.5.6", "prettier": "^3.5.0", "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18" }, diff --git a/scripts/check-apis.ts b/scripts/check-apis.ts new file mode 100644 index 0000000..1583e4f --- /dev/null +++ b/scripts/check-apis.ts @@ -0,0 +1,133 @@ +import axios from "axios"; +import dns from "dns"; +import { promisify } from "util"; +import url from "url"; + +const resolve4 = promisify(dns.resolve4); + +// This script verifies that external logging and analytics APIs are reachable +// from the deployment environment (which could be behind corporate firewalls or VPNs). + +const umamiEndpoint = + process.env.UMAMI_API_ENDPOINT || "https://analytics.infra.mintel.me"; +const sentryDsn = process.env.SENTRY_DSN || ""; + +async function checkUmami() { + console.log(`\nšŸ” Checking Umami Analytics API Availability...`); + console.log(` Endpoint: ${umamiEndpoint}`); + + try { + // Umami usually exposes a /api/heartbeat or /api/health if we know the route. + // Trying root or /api/auth/verify (which will give 401 but proves routing works). + // A simple GET to the configured endpoint should return a 200 or 401, not a 5xx/timeout. + const response = await axios.get( + `${umamiEndpoint.replace(/\/$/, "")}/api/health`, + { + timeout: 5000, + validateStatus: () => true, // Accept any status, we just want to know it's reachable and not 5xx + }, + ); + + // As long as it's not a 502/503/504 Bad Gateway/Timeout, the service is "up" from our perspective + if (response.status >= 500) { + throw new Error( + `Umami API responded with server error HTTP ${response.status}`, + ); + } + + console.log(` āœ… Umami Analytics is reachable (HTTP ${response.status})`); + return true; + } catch (err: any) { + // If /api/health fails completely, maybe try a DNS check as a fallback + try { + console.warn( + ` āš ļø HTTP check failed, falling back to DNS resolution...`, + ); + const umamiHost = new url.URL(umamiEndpoint).hostname; + await resolve4(umamiHost); + console.log( + ` āœ… Umami Analytics DNS resolved successfully (${umamiHost})`, + ); + return true; + } catch (dnsErr: any) { + console.error( + ` āŒ CRITICAL: Umami Analytics is completely unreachable! ${err.message} | DNS: ${dnsErr.message}`, + ); + return false; + } + } +} + +async function checkSentry() { + console.log(`\nšŸ” Checking Glitchtip/Sentry Error Tracking Availability...`); + + if (!sentryDsn) { + console.log(` ā„¹ļø No SENTRY_DSN provided in environment. Skipping.`); + return true; + } + + try { + const parsedDsn = new url.URL(sentryDsn); + const host = parsedDsn.hostname; + console.log(` Host: ${host}`); + + // We do a DNS lookup to ensure the runner can actually resolve the tracking server + const addresses = await resolve4(host); + + if (addresses && addresses.length > 0) { + console.log(` āœ… Glitchtip/Sentry domain resolved: ${addresses[0]}`); + + // Optional: Quick TCP/HTTP check to the host root (Glitchtip usually runs on 80/443 root) + try { + const proto = parsedDsn.protocol || "https:"; + await axios.get(`${proto}//${host}/api/0/`, { + timeout: 5000, + validateStatus: () => true, + }); + console.log(` āœ… Glitchtip/Sentry API root responds to HTTP.`); + } catch (ignore) { + console.log( + ` āš ļø Glitchtip/Sentry HTTP ping failed or timed out, but DNS is valid. Proceeding.`, + ); + } + + return true; + } + throw new Error("No IP addresses found for DSN host"); + } catch (err: any) { + console.error( + ` āŒ CRITICAL: Glitchtip/Sentry DSN is invalid or hostname is unresolvable! ${err.message}`, + ); + return false; + } +} + +async function main() { + console.log("šŸš€ Starting External API Connectivity Smoke Test..."); + + let hasErrors = false; + + const umamiOk = await checkUmami(); + if (!umamiOk) hasErrors = true; + + const sentryOk = await checkSentry(); + if (!sentryOk) hasErrors = true; + + if (hasErrors) { + console.error( + `\n🚨 POST-DEPLOY CHECK FAILED: One or more critical external APIs are unreachable.`, + ); + console.error( + ` This might mean the deployment environment lacks outbound internet access, `, + ); + console.error( + ` DNS is misconfigured, or the upstream services are down.`, + ); + process.exit(1); + } + + console.log(`\nšŸŽ‰ SUCCESS: All required external APIs are reachable!`); + process.exit(0); +} + +main(); diff --git a/scripts/check-http.ts b/scripts/check-http.ts new file mode 100644 index 0000000..17334cc --- /dev/null +++ b/scripts/check-http.ts @@ -0,0 +1,83 @@ +import axios from "axios"; +import * as cheerio from "cheerio"; + +const targetUrl = + process.argv[2] || + process.env.NEXT_PUBLIC_BASE_URL || + "http://localhost:3000"; +const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "lassmichrein"; + +async function main() { + console.log(`\nšŸš€ Starting HTTP Sitemap Validation for: ${targetUrl}\n`); + + try { + const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`; + console.log(`šŸ“„ Fetching sitemap from ${sitemapUrl}...`); + + const response = await axios.get(sitemapUrl, { + headers: { Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}` }, + validateStatus: (status) => status < 400, + }); + + const $ = cheerio.load(response.data, { xmlMode: true }); + let urls = $("url loc") + .map((i, el) => $(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} target URLs in sitemap.`); + + if (urls.length === 0) { + console.error("āŒ No URLs found in sitemap. Is the site up?"); + process.exit(1); + } + + console.log(`\nšŸ” Verifying HTTP Status Codes (Limit: None)...`); + let hasErrors = false; + + // Run fetches sequentially to avoid overwhelming the server during CI + for (let i = 0; i < urls.length; i++) { + const u = urls[i]; + try { + const res = await axios.get(u, { + headers: { + Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}`, + }, + validateStatus: null, // Don't throw on error status + }); + + if (res.status >= 400) { + console.error(`āŒ ERROR ${res.status}: ${res.statusText} -> ${u}`); + hasErrors = true; + } else { + console.log(`āœ… OK ${res.status} -> ${u}`); + } + } catch (err: any) { + console.error(`āŒ NETWORK ERROR: ${err.message} -> ${u}`); + hasErrors = true; + } + } + + if (hasErrors) { + console.error( + `\nāŒ HTTP Sitemap Validation Failed. One or more pages returned an error.`, + ); + process.exit(1); + } else { + console.log( + `\n✨ Success: All ${urls.length} pages are healthy! (HTTP 200)`, + ); + process.exit(0); + } + } catch (error: any) { + console.error(`\nāŒ Critical Error during Sitemap Fetch:`, error.message); + process.exit(1); + } +} + +main(); diff --git a/scripts/check-locale.ts b/scripts/check-locale.ts new file mode 100644 index 0000000..a48ba68 --- /dev/null +++ b/scripts/check-locale.ts @@ -0,0 +1,197 @@ +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 || "lassmichrein"; + +// Expected slug translations: German key → English value +const SLUG_MAP: Record = { + // Add translations if mb-grid translates URLs: e.g. produkte: 'products' +}; + +// Reverse map: English → German +const REVERSE_SLUG_MAP: Record = Object.fromEntries( + Object.entries(SLUG_MAP).map(([de, en]) => [en, de]), +); + +const headers = { Cookie: `mintel_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); +});