// Optional browser QA: npm install in this directory, then npm test. const { chromium } = require('playwright'); const sharp = require('sharp'); const fs = require('node:fs/promises'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); const root = path.resolve(__dirname, '../..'); async function ready(page) { await page.waitForFunction(() => document.getElementById('preview-frame').style.display === 'block' && document.getElementById('loading').style.display === 'none'); const frame = page.frameLocator('#preview-frame'); await frame.locator('body').waitFor(); return frame; } (async () => { const browser = await chromium.launch({headless:true, executablePath:process.env.BROWSER_EXECUTABLE_PATH || undefined}); try { const page = await browser.newPage({viewport:{width:1650,height:1050}}); const errors = []; page.on('pageerror', error => errors.push(error.message)); await page.goto(pathToFileURL(path.join(root,'preview/index.html')).href); await page.addStyleTag({content:'*{transition:none!important;animation:none!important}'}); await ready(page); // Floating inspector is preview chrome, not part of the rendered email. if (await page.locator('#panel-close').isVisible()) await page.click('#panel-close'); const languages = await page.locator('#sel-locale option').evaluateAll(options => options.map(o=>o.value)); const themes = await page.locator('#sel-theme option').evaluateAll(options => options.map(o=>o.value)); const types = await page.locator('#sel-template option').evaluateAll(options => options.map(o=>o.value)); const buttonColors = {aurora:'rgb(224, 245, 243)',bloom:'rgb(255, 255, 255)',ember:'rgb(255, 255, 255)',heritage:'rgb(255, 255, 255)',horizon:'rgb(255, 255, 255)',ink:'rgb(254, 253, 251)',mono:'rgb(0, 0, 0)',neon:'rgb(10, 10, 15)',terminal:'rgb(13, 17, 23)',terra:'rgb(255, 255, 255)'}; if (!languages.includes('en-US') || !languages.includes('zh-CN')) throw new Error('official languages missing'); for (const lang of languages) { await page.selectOption('#sel-locale',lang); const frame = await ready(page); if (!(await frame.locator('body').textContent()).trim()) throw new Error('empty '+lang+' preview'); } await page.selectOption('#sel-locale','pl-PL'); await page.selectOption('#sel-template','team_invite'); await ready(page); await page.waitForFunction(()=>document.getElementById('locale-message')?.textContent.includes('[UPSTREAM-WARN]')); await page.selectOption('#sel-locale','zh-CN'); await page.selectOption('#sel-locale','en-US'); await ready(page); const screenshotDir = path.join(root,'build','screenshots'); await fs.mkdir(screenshotDir,{recursive:true}); for (const theme of themes) { await page.selectOption('#sel-theme',theme); await page.selectOption('#sel-template','register_notify'); await page.click('#btn-desk'); const desktop = await ready(page); const cta = desktop.locator('.card-body .email-btn').first(); const color = await cta.evaluate(el=>getComputedStyle(el).color); if (buttonColors[theme] && color!==buttonColors[theme]) throw new Error('CTA styling overridden: '+theme+' '+color); const label = (await cta.textContent()).trim(); if (!label || /^https?:/i.test(label)) throw new Error('button displays URL: '+theme); const fallback = desktop.locator('.email-link-url a').first(); if (await cta.getAttribute('href') !== await fallback.getAttribute('href')) throw new Error('button/fallback target mismatch: '+theme); const logo = desktop.locator('img.email-logo'); if (await logo.count()) { if (!(await logo.getAttribute('src')).startsWith('data:image/png;base64,')) throw new Error('preview logo is not generated official asset: '+theme); if (!(await logo.evaluate(img=>img.complete && img.naturalWidth>0))) throw new Error('logo failed to load: '+theme); } // Capture the actual 600px email, not the wide preview canvas/inspector. await page.waitForFunction(()=>document.getAnimations().every(a=>a.playState!=='running')); const box = await desktop.locator('.email-wrap').boundingBox(); if (!box) throw new Error('email frame missing: '+theme); const png = await page.screenshot({clip:box,animations:'disabled'}); const background = await desktop.locator('body').evaluate(el=>getComputedStyle(el).backgroundColor); const image = await sharp(png).extend({top:16,bottom:16,left:16,right:16,background}).resize({width:600}).png({compressionLevel:9}).toBuffer(); await fs.writeFile(path.join(screenshotDir,theme+'.png'),image); for (const type of types) { await page.selectOption('#sel-template',type); await page.click('#btn-mob'); const frame = await ready(page); const overflow = await frame.locator('html').evaluate(el=>el.scrollWidth > window.innerWidth + 1); if (overflow) throw new Error('mobile overflow: '+theme+'/'+type); } } if (errors.length) throw new Error(errors.join('\n')); console.log('[PASS] file:// language bundles, warning, rapid switching, '+themes.length+' theme screenshots and mobile overflow'); // A running dev server can be included without making static QA depend on it. if (process.env.PREVIEW_DEV_URL) { await page.goto(process.env.PREVIEW_DEV_URL); await ready(page); await page.selectOption('#sel-locale','zh-CN'); await ready(page); console.log('[PASS] HTTP preview and language switching'); } if (process.argv.includes('--update-gallery')) { for (const theme of themes) { const image=await fs.readFile(path.join(screenshotDir,theme+'.png')); if (image.length>50*1024) throw new Error('gallery image exceeds 50 KiB: '+theme); await fs.writeFile(path.join(root,'docs','images',theme+'.png'),image); } console.log('[PASS] gallery screenshots updated'); } } finally { await browser.close(); } })().catch(error=>{console.error(error);process.exitCode=1;});