Compare commits

...

9 Commits

Author SHA1 Message Date
eebe7972e0 style: update recent posts layout to 4 columns matching product categories and fix payload cms text typography styling
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 2m39s
Build & Deploy / 🏗️ Build (push) Successful in 3m50s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 6m22s
Build & Deploy / 🔔 Notify (push) Successful in 6s
2026-02-27 18:34:06 +01:00
a9c7fa7c5e chore(deps): refresh @mintel package checksums in lockfile
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 3m5s
Build & Deploy / 🏗️ Build (push) Successful in 3m28s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 6m3s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-27 18:15:29 +01:00
85e7ff71d5 ci: fix gitea composite action clone url
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 2m11s
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 / 🔔 Notify (push) Successful in 1s
2026-02-27 18:08:55 +01:00
2acb0c1608 chore(deps): remove unused three.js and react-three packages
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m40s
Build & Deploy / 🏗️ Build (push) Successful in 3m26s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 7s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-27 15:37:13 +01:00
082733c4f4 ci: inject PUPPETEER_EXECUTABLE_PATH for headless form tests
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m15s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-27 15:33:54 +01:00
af67ae7994 ci: replace individual smoke tests with core-smoke-tests composite action
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-02-27 15:33:22 +01:00
1fd247e358 ci: add missing check:forms step to post-deploy verification
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m21s
Build & Deploy / 🏗️ Build (push) Successful in 3m29s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m10s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-02-27 15:19:29 +01:00
44401cf546 chore(ci): implement robust E2E form testing with puppeteer gatekeeper bypass
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m16s
Build & Deploy / 🏗️ Build (push) Successful in 3m31s
Build & Deploy / 🚀 Deploy (push) Successful in 13s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m23s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-27 15:05:09 +01:00
7f106b1fa7 ci: decouple heavy smoke tests into dedicated qa pipeline and add api checks 2026-02-27 14:04:45 +01:00
9 changed files with 642 additions and 718 deletions

View File

@@ -510,68 +510,22 @@ jobs:
env:
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
run: pnpm run check:og
- name: 🌐 Full Sitemap HTTP Validation
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
if: always() && steps.deps.outcome == 'success'
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
with:
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
run: pnpm run check:http
- name: 🌐 Locale & Language Switcher Validation
if: always() && steps.deps.outcome == 'success'
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
run: pnpm run check:locale
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
# ── Quality Gates (informational, don't block pipeline) ───────────────
- name: 🌐 HTML DOM Validation
- name: 📝 E2E Form Submission Test
if: always() && steps.deps.outcome == 'success'
continue-on-error: true
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
run: pnpm check:html
- name: 🔒 Security Headers Scan
if: always() && steps.deps.outcome == 'success'
continue-on-error: true
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
run: pnpm check:security
- name: 🔗 Lychee Deep Link Crawl
if: always() && steps.deps.outcome == 'success'
continue-on-error: true
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
run: pnpm check:links
- name: 🖼️ Dynamic Asset & Image Integrity Scan
if: always() && steps.deps.outcome == 'success'
continue-on-error: true
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
CHROME_PATH: /usr/bin/chromium
run: pnpm check:assets
- name: ⚡ Lighthouse CI
if: always() && steps.deps.outcome == 'success'
continue-on-error: true
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
CHROME_PATH: /usr/bin/chromium
PAGESPEED_LIMIT: 8
run: pnpm run pagespeed:test
- name: ♿ WCAG Audit
if: always() && steps.deps.outcome == 'success'
continue-on-error: true
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
CHROME_PATH: /usr/bin/chromium
PAGESPEED_LIMIT: 8
run: pnpm run check:wcag
run: pnpm run check:forms
# ──────────────────────────────────────────────────────────────────────────────
# JOB 7: Notifications

17
.gitea/workflows/qa.yml Normal file
View File

@@ -0,0 +1,17 @@
name: Nightly QA
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
call-qa-workflow:
uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main
with:
TARGET_URL: 'https://testing.klz-cables.com'
PROJECT_NAME: 'klz-2026'
secrets:
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}

View File

@@ -123,11 +123,87 @@ const jsxConverters: JSXConverters = {
return <span key="text">{text}</span>;
},
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
paragraph: ({ children }: any) => <div className="mb-4 leading-relaxed">{children}</div>,
// Scale headings to prevent multiple H1s (H1 -> H2, etc)
h1: ({ children }: any) => <h2 className="text-3xl md:text-4xl font-bold my-6">{children}</h2>,
h2: ({ children }: any) => <h3 className="text-2xl md:text-3xl font-bold my-5">{children}</h3>,
h3: ({ children }: any) => <h4 className="text-xl md:text-2xl font-bold my-4">{children}</h4>,
paragraph: ({ children }: any) => (
<div className="mb-6 leading-relaxed text-text-secondary">{children}</div>
),
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
heading: ({ node, children }: any) => {
const tag = node?.tag;
if (tag === 'h1')
return (
<h2 className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary">{children}</h2>
);
if (tag === 'h2')
return (
<h3 className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary">{children}</h3>
);
if (tag === 'h3')
return (
<h4 className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary">{children}</h4>
);
if (tag === 'h4')
return (
<h5 className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary">{children}</h5>
);
if (tag === 'h5')
return (
<h6 className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary">{children}</h6>
);
return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>;
},
list: ({ node, children }: any) => {
if (node?.listType === 'number') {
return (
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
{children}
</ol>
);
}
if (node?.listType === 'check') {
return <ul className="list-none pl-0 my-6 space-y-2 text-text-secondary">{children}</ul>;
}
return (
<ul className="list-disc pl-6 my-6 space-y-2 text-text-secondary marker:text-primary">
{children}
</ul>
);
},
listitem: ({ node, children }: any) => {
if (node?.checked != null) {
return (
<li className="flex items-center gap-3 mb-2 leading-relaxed">
<input
type="checkbox"
checked={node.checked}
readOnly
className="mt-1 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded"
/>
<span>{children}</span>
</li>
);
}
return <li className="mb-2 leading-relaxed">{children}</li>;
},
quote: ({ children }: any) => (
<blockquote className="border-l-4 border-primary bg-primary/5 rounded-r-2xl pl-6 py-4 my-8 italic text-text-secondary shadow-sm">
{children}
</blockquote>
),
link: ({ node, children }: any) => {
// Handling Payload CMS link nodes
const href = node?.fields?.url || node?.url || '#';
const newTab = node?.fields?.newTab || node?.newTab;
return (
<a
href={href}
target={newTab ? '_blank' : undefined}
rel={newTab ? 'noopener noreferrer' : undefined}
className="text-primary no-underline hover:underline font-medium transition-colors"
>
{children}
</a>
);
},
blocks: {
// ... preserved existing blocks ...

View File

@@ -3,7 +3,7 @@ import Image from 'next/image';
import Link from 'next/link';
import { getAllPosts } from '@/lib/blog';
import { getTranslations } from 'next-intl/server';
import { Section, Container, Heading, Card, Badge } from '../../components/ui';
import { Section, Container, Heading } from '../../components/ui';
interface RecentPostsProps {
locale: string;
@@ -13,7 +13,7 @@ interface RecentPostsProps {
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
const t = await getTranslations('Blog');
const posts = await getAllPosts(locale);
const recentPosts = posts.slice(0, 3);
const recentPosts = posts.slice(0, 4);
if (recentPosts.length === 0) return null;
@@ -21,9 +21,9 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
const subtitle = data?.subtitle || t('latestNews');
return (
<Section className="bg-neutral py-16 md:py-24">
<Container>
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0">
<Container className="py-12 md:py-16">
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6">
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
{title}
</Heading>
@@ -35,91 +35,73 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
<span className="ml-2 transition-transform group-hover:translate-x-2">&rarr;</span>
</Link>
</div>
<ul className="grid grid-cols-1 gap-10 list-none p-0 m-0">
{recentPosts.map((post, idx) => (
<li key={`${post.slug}-${idx}`}>
<Link
href={`/${locale}/blog/${post.slug}`}
className="group block h-full focus:outline-none"
>
<Card
tag="article"
className="relative flex flex-col justify-end border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl overflow-hidden min-h-[400px] md:min-h-[450px]"
>
{post.frontmatter.featuredImage && (
<>
<Image
src={post.frontmatter.featuredImage.split('?')[0]}
alt={post.frontmatter.title}
fill
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
style={{
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
}}
sizes="(max-width: 768px) 100vw, 100vw"
loading="lazy"
/>
<div className="absolute inset-0 bg-neutral-dark/10 group-hover:bg-neutral-dark/5 transition-colors duration-500" />
</>
)}
<div className="relative z-10 w-full p-6 md:p-8 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/60 to-transparent flex flex-col pt-32">
<div className="flex flex-wrap items-center gap-4 mb-4">
{post.frontmatter.category && (
<Badge variant="accent" className="shadow-md">
{post.frontmatter.category}
</Badge>
)}
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
Draft Preview
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</div>
<h3 className="text-xl md:text-2xl font-bold text-white mb-4 group-hover:text-accent transition-colors drop-shadow-md leading-tight max-w-4xl">
{post.frontmatter.title}
</h3>
<div className="mt-auto flex items-center justify-between border-t border-white/20 pt-6">
<span className="text-accent text-sm md:text-base font-extrabold group-hover:text-white transition-colors">
{t('readMore')}
</span>
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-accent group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 backdrop-blur-sm border border-white/20">
<svg
className="w-5 h-5 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
</div>
</Card>
</Link>
</li>
))}
</ul>
</Container>
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none">
{recentPosts.map((post, idx) => (
<li key={`${post.slug}-${idx}`} className="block">
<Link
href={`/${locale}/blog/${post.slug}`}
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0 focus:outline-none"
>
{post.frontmatter.featuredImage && (
<>
<Image
src={post.frontmatter.featuredImage.split('?')[0]}
alt={post.frontmatter.title}
fill
className="object-cover transition-transform duration-1000 group-hover:scale-110"
style={{
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
}}
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
loading="lazy"
/>
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
</>
)}
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
<div className="flex flex-wrap items-center gap-2 mb-4">
{post.frontmatter.category && (
<span className="px-3 py-1 bg-accent text-primary-dark rounded-full text-[10px] md:text-xs font-bold uppercase tracking-wider shadow-sm">
{post.frontmatter.category}
</span>
)}
<time
dateTime={post.frontmatter.date}
suppressHydrationWarning
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
Draft Preview
</span>
)}
</div>
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md">
{post.frontmatter.title}
</h3>
</div>
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
{t('readMore')}{' '}
<span className="ml-2 transition-transform group-hover:translate-x-2">
&rarr;
</span>
</div>
</div>
</Link>
</li>
))}
</ul>
</Section>
);
}

View File

@@ -15,9 +15,6 @@
"@payloadcms/ui": "^3.77.0",
"@react-email/components": "^1.0.7",
"@react-pdf/renderer": "^4.3.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@react-three/postprocessing": "^3.0.4",
"@sentry/nextjs": "^10.39.0",
"@types/recharts": "^2.0.1",
"axios": "^1.13.5",
@@ -48,7 +45,6 @@
"sharp": "^0.34.5",
"svg-to-pdfkit": "^0.1.8",
"tailwind-merge": "^3.4.0",
"three": "^0.183.1",
"xlsx": "npm:@e965/xlsx@^0.20.3",
"zod": "3.25.76"
},
@@ -115,6 +111,8 @@
"check:security": "tsx ./scripts/check-security.ts",
"check:links": "bash ./scripts/check-links.sh",
"check:assets": "tsx ./scripts/check-broken-assets.ts",
"check:forms": "tsx ./scripts/check-forms.ts",
"check:apis": "tsx ./scripts/check-apis.ts",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
"cms:migrate": "payload migrate",

733
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

119
scripts/check-apis.ts Normal file
View File

@@ -0,0 +1,119 @@
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();

170
scripts/check-forms.ts Normal file
View File

@@ -0,0 +1,170 @@
import puppeteer, { HTTPResponse } from 'puppeteer';
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 || 'klz2026';
async function main() {
console.log(`\n🚀 Starting E2E Form Submission Check for: ${targetUrl}`);
// 1. Fetch Sitemap to discover the contact page and a product page
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
let urls: string[] = [];
try {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
});
const $ = cheerio.load(response.data, { xmlMode: true });
urls = $('url loc')
.map((i, el) => $(el).text())
.get();
// Normalize to target URL instance
const urlPattern = /https?:\/\/[^\/]+/;
urls = [...new Set(urls)]
.filter((u) => u.startsWith('http'))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
.sort();
} catch (err: any) {
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
process.exit(1);
}
const contactUrl = urls.find((u) => u.includes('/de/kontakt'));
// Ensure we select an actual product page (depth >= 7: http://host/de/produkte/category/product)
const productUrl = urls.find(
(u) =>
u.includes('/de/produkte/') && new URL(u).pathname.split('/').filter(Boolean).length >= 4,
);
if (!contactUrl) {
console.error(`❌ Could not find contact page in sitemap. Ensure /de/kontakt exists.`);
process.exit(1);
}
if (!productUrl) {
console.error(
`❌ Could not find a product page in sitemap. Form testing requires at least one product page.`,
);
process.exit(1);
}
console.log(`✅ Discovered Contact Page: ${contactUrl}`);
console.log(`✅ Discovered Product Page: ${productUrl}`);
// 2. Launch Headless Browser
console.log(`\n🕷 Launching Puppeteer Headless Engine...`);
const browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || undefined,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
// 3. Inject Gatekeeper session bypassing auth screens
console.log(`\n🛡 Injecting Gatekeeper Session...`);
await page.setCookie({
name: 'klz_gatekeeper_session',
value: gatekeeperPassword,
domain: new URL(targetUrl).hostname,
path: '/',
httpOnly: true,
secure: targetUrl.startsWith('https://'),
});
let hasErrors = false;
// 4. Test Contact Form
try {
console.log(`\n🧪 Testing Contact Form on: ${contactUrl}`);
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
// Ensure React has hydrated completely
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {});
// Ensure form is visible and interactive
try {
// Find the form input by name
await page.waitForSelector('input[name="name"]', { visible: true, timeout: 15000 });
} catch (e) {
console.error('Failed to find Contact Form input. Page Title:', await page.title());
throw e;
}
// Fill form fields
await page.type('input[name="name"]', 'Automated E2E Test');
await page.type('input[name="email"]', 'testing@mintel.me');
await page.type(
'textarea[name="message"]',
'This is an automated test verifying the contact form submission.',
);
console.log(` Submitting Contact Form...`);
// Explicitly click submit and wait for navigation/state-change
await Promise.all([
page.waitForSelector('[role="alert"][aria-live="polite"]', { timeout: 15000 }),
page.click('button[type="submit"]'),
]);
console.log(`✅ Contact Form submitted successfully! (Success state verified)`);
} catch (err: any) {
console.error(`❌ Contact Form Test Failed: ${err.message}`);
hasErrors = true;
}
// 4. Test Product Quote Form
try {
console.log(`\n🧪 Testing Product Quote Form on: ${productUrl}`);
await page.goto(productUrl, { waitUntil: 'networkidle0', timeout: 30000 });
// Ensure React has hydrated completely
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {});
// The product form uses dynamic IDs, so we select by input type in the specific form context
try {
await page.waitForSelector('form input[type="email"]', { visible: true, timeout: 15000 });
} catch (e) {
console.error('Failed to find Product Quote Form input. Page Title:', await page.title());
throw e;
}
// In RequestQuoteForm, the email input is type="email" and message is a textarea.
await page.type('form input[type="email"]', 'testing@mintel.me');
await page.type(
'form textarea',
'Automated request for product quote via E2E testing framework.',
);
console.log(` Submitting Product Quote Form...`);
// Submit and wait for success state
await Promise.all([
page.waitForSelector('[role="alert"][aria-live="polite"]', { timeout: 15000 }),
page.click('form button[type="submit"]'),
]);
console.log(`✅ Product Quote Form submitted successfully! (Success state verified)`);
} catch (err: any) {
console.error(`❌ Product Quote Form Test Failed: ${err.message}`);
hasErrors = true;
}
await browser.close();
// 5. Evaluation
if (hasErrors) {
console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`);
process.exit(1);
} else {
console.log(`\n🎉 SUCCESS: All form submissions arrived and handled correctly!`);
process.exit(0);
}
}
main();

View File

@@ -50,7 +50,12 @@ async function main() {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
validateStatus: (status) => status < 400,
});
const filename = `page-${i}.html`;
// Generate a safe filename that retains URL information
const urlStr = new URL(u);
const safePath = (urlStr.pathname + urlStr.search).replace(/[^a-zA-Z0-9]/g, '_');
const filename = `${safePath || 'index'}.html`;
fs.writeFileSync(path.join(outputDir, filename), res.data);
} catch (err: any) {
console.error(`❌ HTTP Error fetching ${u}: ${err.message}`);