Build & Deploy / 🔍 Prepare (push) Successful in 1m7s
Nightly QA / 🔗 Links & Deps (push) Failing after 2m43s
Build & Deploy / 🧪 QA (push) Has been cancelled
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
Nightly QA / 🔍 Static Analysis (push) Has been cancelled
Nightly QA / ♿ Accessibility (push) Has started running
Nightly QA / 🎭 Lighthouse (push) Has started running
Nightly QA / 🔔 Notify (push) Has been cancelled
198 lines
6.6 KiB
TypeScript
198 lines
6.6 KiB
TypeScript
import puppeteer, { HTTPResponse } from 'puppeteer';
|
|
import axios from 'axios';
|
|
import * as cheerio from 'cheerio';
|
|
|
|
const targetUrl =
|
|
process.argv.find((arg) => !arg.startsWith('--') && arg.startsWith('http')) ||
|
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
|
'http://localhost:3000';
|
|
const limit = process.env.ASSET_CHECK_LIMIT ? parseInt(process.env.ASSET_CHECK_LIMIT) : 20;
|
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
|
|
|
async function main() {
|
|
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
|
|
console.log(`📊 Limit: ${limit} pages\n`);
|
|
|
|
// 1. Fetch Sitemap to discover all routes
|
|
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();
|
|
|
|
console.log(`✅ Found ${urls.length} target URLs.`);
|
|
|
|
if (urls.length > limit) {
|
|
console.log(
|
|
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
|
|
);
|
|
// Simplify selection: home pages + a slice of the rest
|
|
const homeEN = urls.filter((u) => u.endsWith('/en') || u === targetUrl);
|
|
const homeDE = urls.filter((u) => u.endsWith('/de'));
|
|
const others = urls.filter((u) => !homeEN.includes(u) && !homeDE.includes(u));
|
|
urls = [...homeEN, ...homeDE, ...others.slice(0, limit - (homeEN.length + homeDE.length))];
|
|
}
|
|
} catch (err: any) {
|
|
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// 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();
|
|
|
|
// Inject Gatekeeper session bypassing auth screens
|
|
await page.setCookie({
|
|
name: 'klz_gatekeeper_session',
|
|
value: gatekeeperPassword,
|
|
domain: new URL(targetUrl).hostname,
|
|
path: '/',
|
|
httpOnly: true,
|
|
secure: targetUrl.startsWith('https://'),
|
|
});
|
|
|
|
let hasBrokenAssets = false;
|
|
let hasConsoleErrors = false;
|
|
const brokenAssetsList: Array<{ url: string; status: number; page: string }> = [];
|
|
const consoleErrorsList: Array<{ type: string; error: string; page: string }> = [];
|
|
|
|
// Listen for unhandled exceptions natively in the page
|
|
page.on('pageerror', (err: any) => {
|
|
consoleErrorsList.push({
|
|
type: 'PAGE_ERROR',
|
|
error: err.message,
|
|
page: page.url(),
|
|
});
|
|
hasConsoleErrors = true;
|
|
});
|
|
|
|
// Listen for console.error and console.warn messages (like Next.js Image warnings, hydration errors, CSP blocks)
|
|
page.on('console', (msg) => {
|
|
const type = msg.type();
|
|
if (type === 'error' || type === 'warn') {
|
|
const text = msg.text();
|
|
|
|
// Exclude common browser extension noise or third party tracker warnings
|
|
if (
|
|
text.includes('google-analytics') ||
|
|
text.includes('googletagmanager') ||
|
|
text.includes('SES Removing unpermitted intrinsics') ||
|
|
text.includes('Third-party cookie will be blocked') ||
|
|
text.includes('Fast Refresh')
|
|
)
|
|
return;
|
|
|
|
consoleErrorsList.push({
|
|
type: type.toUpperCase(),
|
|
error: text,
|
|
page: page.url(),
|
|
});
|
|
hasConsoleErrors = true;
|
|
}
|
|
});
|
|
|
|
// Listen to ALL network responses
|
|
page.on('response', (response: HTTPResponse) => {
|
|
const status = response.status();
|
|
// Catch classic 404s and 500s on ANY fetch/image/script
|
|
if (
|
|
status >= 400 &&
|
|
status !== 999 &&
|
|
!response.url().includes('google-analytics') &&
|
|
!response.url().includes('googletagmanager')
|
|
) {
|
|
const type = response.request().resourceType();
|
|
|
|
// We explicitly care about images, stylesheets, scripts, and fetch requests (API) getting 404/500s.
|
|
if (['image', 'script', 'stylesheet', 'fetch', 'xhr'].includes(type)) {
|
|
brokenAssetsList.push({
|
|
url: response.url(),
|
|
status: status,
|
|
page: page.url(),
|
|
});
|
|
hasBrokenAssets = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 3. Scan each page
|
|
for (let i = 0; i < urls.length; i++) {
|
|
const u = urls[i];
|
|
console.log(`[${i + 1}/${urls.length}] Scanning: ${u}`);
|
|
try {
|
|
// Wait until network is idle to ensure all Next.js hydration and image lazy-loads trigger
|
|
await page.goto(u, { waitUntil: 'networkidle0', timeout: 30000 });
|
|
|
|
// Force scroll to bottom to trigger any IntersectionObserver lazy-loaded images
|
|
await page.evaluate(async () => {
|
|
await new Promise<void>((resolve) => {
|
|
let totalHeight = 0;
|
|
const distance = 100;
|
|
const timer = setInterval(() => {
|
|
const scrollHeight = document.body.scrollHeight;
|
|
window.scrollBy(0, distance);
|
|
totalHeight += distance;
|
|
if (totalHeight >= scrollHeight) {
|
|
clearInterval(timer);
|
|
resolve();
|
|
}
|
|
}, 100);
|
|
});
|
|
});
|
|
|
|
// Wait a tiny bit more for final lazy loads
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
} catch (err: any) {
|
|
console.error(`⚠️ Timeout or navigation error on ${u}: ${err.message}`);
|
|
// Don't fail the whole script just because one page timed out, but flag it
|
|
hasBrokenAssets = true;
|
|
}
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
// 4. Report Results
|
|
if (hasBrokenAssets && brokenAssetsList.length > 0) {
|
|
console.error(`\n❌ FATAL: Broken assets (404/500) detected heavily on the site!`);
|
|
console.table(brokenAssetsList);
|
|
}
|
|
|
|
if (hasConsoleErrors && consoleErrorsList.length > 0) {
|
|
console.error(`\n❌ FATAL: Console Errors/Warnings detected on the site!`);
|
|
console.table(consoleErrorsList);
|
|
}
|
|
|
|
if (hasBrokenAssets || hasConsoleErrors) {
|
|
console.error(`\n🚨 The CI build will now fail to prevent bad code from reaching production.`);
|
|
process.exit(1);
|
|
} else {
|
|
console.log(
|
|
`\n🎉 SUCCESS: All ${urls.length} pages rendered perfectly with 0 broken images or console errors!`,
|
|
);
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
main();
|