ci: add missing core smoke test scripts and dependencies
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 22s
Build & Deploy / 🧪 QA (push) Failing after 1m5s
Build & Deploy / 🏗️ Build (push) Successful in 4m47s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 22s
Build & Deploy / 🧪 QA (push) Failing after 1m5s
Build & Deploy / 🏗️ Build (push) Successful in 4m47s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
This commit is contained in:
@@ -13,7 +13,10 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"generate:types": "payload generate:types",
|
"generate:types": "payload generate:types",
|
||||||
"generate:importmap": "payload generate:importmap",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -62,6 +65,8 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "15.1.6",
|
"eslint-config-next": "15.1.6",
|
||||||
"happy-dom": "^20.6.1",
|
"happy-dom": "^20.6.1",
|
||||||
@@ -72,6 +77,7 @@
|
|||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.5.0",
|
"prettier": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
|
|||||||
133
scripts/check-apis.ts
Normal file
133
scripts/check-apis.ts
Normal file
@@ -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();
|
||||||
83
scripts/check-http.ts
Normal file
83
scripts/check-http.ts
Normal file
@@ -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();
|
||||||
197
scripts/check-locale.ts
Normal file
197
scripts/check-locale.ts
Normal file
@@ -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 <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 || "lassmichrein";
|
||||||
|
|
||||||
|
// Expected slug translations: German key → English value
|
||||||
|
const SLUG_MAP: Record<string, string> = {
|
||||||
|
// Add translations if mb-grid translates URLs: e.g. produkte: 'products'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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: `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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user