import puppeteer from "puppeteer"; const targetUrl = process.env.TEST_URL || "http://localhost:3000"; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "secret"; async function fetchSitemapUrls(baseUrl: string): Promise { const sitemapUrl = `${baseUrl.replace(/\/$/, "")}/sitemap.xml`; console.log(`๐Ÿ“ฅ Fetching sitemap from ${sitemapUrl}...`); try { const response = await fetch(sitemapUrl); const text = await response.text(); // Simple regex to extract loc tags const matches = text.matchAll(/(.*?)<\/loc>/g); let urls = Array.from(matches, (m) => m[1]); // Normalize to target URL instance const urlPattern = /https?:\/\/[^\/]+/; urls = [...new Set(urls)] .filter((u) => u.startsWith("http")) .map((u) => u.replace(urlPattern, baseUrl.replace(/\/$/, ""))) .sort(); console.log(`โœ… Found ${urls.length} target URLs.`); return urls; } catch (err: any) { console.error(`โŒ Failed to fetch sitemap: ${err.message}`); return []; } } async function main() { console.log(`\n๐Ÿš€ Starting Strict Asset Integrity Check for: ${targetUrl}`); let urls = await fetchSitemapUrls(targetUrl); if (urls.length === 0) { console.warn(`โš ๏ธ Falling back to just the homepage.`); urls = [targetUrl]; } // Launch browser with KLZ pattern: use system chromium via env 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", "--disable-gpu", "--ignore-certificate-errors", "--disable-web-security", "--disable-features=IsolateOrigins,site-per-process", ], }); const page = await browser.newPage(); let hasBrokenAssets = false; let currentScannedUrl = urls[0] || ""; // Listen for console logging from the page for debugging page.on("console", (msg) => { const type = msg.type(); // Only capture errors and warnings, not info/logs if (type === "error" || type === "warn") { const text = msg.text(); // Exclude common noise if ( text.includes("google-analytics") || text.includes("googletagmanager") || text.includes("Fast Refresh") ) return; console.log(` [PAGE ${type.toUpperCase()}] ${text}`); } }); page.on("pageerror", (err: Error) => { if (currentScannedUrl.includes("showcase")) return; console.error(` [PAGE EXCEPTION] ${err.message}`); }); // Listen to ALL network responses to catch broken assets (404/500) page.on("response", (response) => { const status = response.status(); // Catch classic 404s and 500s on ANY fetch/image/script if ( status >= 400 && status !== 429 && status !== 999 && !response.url().includes("google-analytics") && !response.url().includes("googletagmanager") ) { const type = response.request().resourceType(); // We explicitly care about images, scripts, stylesheets, and fetches getting 404/500s. if ( ["image", "script", "stylesheet", "fetch", "xhr", "document"].includes( type, ) ) { // Exclude showcase routes from strict sub-asset checking since they proxy external content if ( (currentScannedUrl.includes("showcase") || response.url().includes("showcase")) && type !== "document" ) { return; } console.error( ` [REQUEST FAILED] ${response.url()} - Status: ${status} (${type})`, ); hasBrokenAssets = true; } } }); try { // Authenticate through Gatekeeper console.log(`\n๐Ÿ›ก๏ธ Authenticating through Gatekeeper...`); console.log(` Navigating to: ${urls[0]}`); const response = await page.goto(urls[0], { waitUntil: "domcontentloaded", timeout: 60000, }); // Give Gatekeeper a second to redirect if needed console.log(` Waiting for potential Gatekeeper redirect...`); await new Promise((resolve) => setTimeout(resolve, 3000)); console.log(` Response status: ${response?.status()}`); console.log(` Response URL: ${response?.url()}`); const isGatekeeperPage = await page.$('input[name="password"]'); if (isGatekeeperPage) { await page.type('input[name="password"]', gatekeeperPassword); await Promise.all([ page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 60000, }), page.click('button[type="submit"]'), ]); await new Promise((resolve) => setTimeout(resolve, 3000)); console.log(`โœ… Gatekeeper authentication successful!`); } else { console.log(`โœ… Already authenticated (no Gatekeeper gate detected).`); } // Scan each page console.log(`\n๐Ÿงช Testing all ${urls.length} pages...`); for (let i = 0; i < urls.length; i++) { const u = urls[i]; currentScannedUrl = u; console.log(`\n[${i + 1}/${urls.length}] Scanning: ${u}`); try { await page.goto(u, { waitUntil: "domcontentloaded", timeout: 60000 }); // Simulate a scroll to bottom to trigger lazy-loads if necessary await page.evaluate(async () => { await new Promise((resolve) => { let totalHeight = 0; const distance = 500; const timer = setInterval(() => { const scrollHeight = document.body.scrollHeight; window.scrollBy(0, distance); totalHeight += distance; // Stop scrolling if we reached the bottom or scrolled for more than 5 seconds if (totalHeight >= scrollHeight || totalHeight > 10000) { clearInterval(timer); resolve(); } }, 100); }); }); // Small delay for final hydration and asynchronous asset loading await new Promise((resolve) => setTimeout(resolve, 1500)); const title = await page.title(); console.log(` โœ… Page Title: ${title}`); if (!title) { throw new Error(`Page title is missing.`); } } catch (err: any) { console.error( ` โŒ Timeout or navigation error on ${u}: ${err.message}`, ); hasBrokenAssets = true; } } } catch (err: any) { console.error(`\nโŒ Fatal Test Error: ${err.message}`); // Take a screenshot for debugging on crash try { const screenshotPath = "/tmp/e2e-failure.png"; await page.screenshot({ path: screenshotPath, fullPage: true }); console.log(`๐Ÿ“ธ Screenshot saved to ${screenshotPath}`); } catch { /* ignore */ } hasBrokenAssets = true; } await browser.close(); if (hasBrokenAssets) { console.error( `\n๐Ÿšจ The CI build will now fail to prevent bad code from reaching production.`, ); process.exit(1); } console.log( `\n๐ŸŽ‰ SUCCESS: All ${urls.length} pages rendered perfectly with 0 broken assets!`, ); process.exit(0); } main();