import { chromium } from '@playwright/test'; import fs from 'fs'; import path from 'path'; const testDir = './visual-test'; if (!fs.existsSync(testDir)) { fs.mkdirSync(testDir, { recursive: true }); } const viewports = [ { name: 'mobile', width: 390, height: 844 }, { name: 'tablet', width: 768, height: 1024 }, { name: 'desktop', width: 1440, height: 900 }, ]; const pages = [ { name: 'home', url: 'http://localhost:3000', sections: ['hero', 'services', 'solutions', 'cases', 'stats', 'cta'] }, { name: 'products', url: 'http://localhost:3000/products', sections: ['hero', 'enterprise', 'specialized', 'cta'] }, { name: 'solutions', url: 'http://localhost:3000/solutions', sections: ['hero', 'industries', 'cta'] }, { name: 'services', url: 'http://localhost:3000/services', sections: ['hero', 'service-areas', 'process', 'cta'] }, ]; async function takeSectionScreenshot(page, sectionName, pageName, viewportName) { const sectionSelectors = { hero: 'h1', services: '[class*="service"]', solutions: '[class*="solution"], [class*="industry"]', cases: '[class*="case"], [class*="client"]', stats: '[class*="stat"], [class*="metric"]', cta: '[class*="cta"], h2', enterprise: '[class*="enterprise"], [class*="product"]', specialized: '[class*="specialized"], [class*="product"]', industries: '[class*="industry"], [class*="card"]', 'service-areas': '[class*="service"], [class*="card"]', process: '[class*="process"], [class*="step"]', }; const selector = sectionSelectors[sectionName]; if (!selector) return; try { const element = await page.locator(selector).first(); if (await element.isVisible({ timeout: 2000 })) { await element.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); const screenshotPath = path.join(testDir, `${pageName}_${viewportName}_${sectionName}.png`); await element.screenshot({ path: screenshotPath, timeout: 5000 }); console.log(` ✓ ${sectionName} 截图: ${screenshotPath}`); return true; } } catch (e) { console.log(` - ${sectionName} 跳过: ${e.message.substring(0, 50)}`); } return false; } async function checkTextVisibility(page) { const issues = []; const allTextElements = await page.$$eval('h1, h2, h3, h4, p, span, a', (elements) => { return elements .filter(el => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return ( rect.width > 50 && rect.height > 10 && rect.top >= 0 && rect.top < window.innerHeight && style.opacity !== '0' && style.display !== 'none' && el.textContent.trim().length > 2 ); }) .slice(0, 20) .map(el => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return { tag: el.tagName, text: el.textContent.trim().substring(0, 30), color: style.color, backgroundColor: style.backgroundColor, opacity: style.opacity, fontSize: style.fontSize, zIndex: style.zIndex, mixBlendMode: style.mixBlendMode, top: Math.round(rect.top), left: Math.round(rect.left), width: Math.round(rect.width), height: Math.round(rect.height), }; }); }); for (const el of allTextElements) { if (el.opacity < 0.5 && el.opacity !== '1') { issues.push(`低透明度文字: ${el.tag} "${el.text}" (opacity: ${el.opacity})`); } if (el.mixBlendMode && el.mixBlendMode !== 'normal') { issues.push(`混合模式文字: ${el.tag} "${el.text}" (mix-blend: ${el.mixBlendMode})`); } } return { allTextElements, issues }; } async function runVisualTest() { console.log('========== 视觉测试开始 ==========\n'); const browser = await chromium.launch(); const results = { total: 0, passed: 0, failed: 0, issues: [], }; for (const viewport of viewports) { console.log(`\n【视口: ${viewport.name} (${viewport.width}x${viewport.height})】`); const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height } }); const page = await context.newPage(); for (const pageInfo of pages) { console.log(`\n 页面: ${pageInfo.name}`); results.total++; try { await page.goto(pageInfo.url, { waitUntil: 'domcontentloaded', timeout: 45000 }); await page.waitForTimeout(3000); const title = await page.title(); console.log(` ✓ 页面标题: ${title}`); const fullPagePath = path.join(testDir, `${pageInfo.name}_${viewport.name}_full.png`); await page.screenshot({ path: fullPagePath, fullPage: true, timeout: 10000 }); console.log(` ✓ 全页截图: ${fullPagePath}`); await page.evaluate(() => window.scrollTo(0, 0)); await page.waitForTimeout(500); const visibilityResult = await checkTextVisibility(page); if (visibilityResult.issues.length > 0) { console.log(` ⚠ 文字可见性问题:`); for (const issue of visibilityResult.issues.slice(0, 5)) { console.log(` - ${issue}`); results.issues.push(`[${viewport.name}/${pageInfo.name}] ${issue}`); } } else { console.log(` ✓ 文字可见性正常`); } console.log(` 区域截图:`); for (const section of pageInfo.sections) { await takeSectionScreenshot(page, section, pageInfo.name, viewport.name); } results.passed++; } catch (error) { console.log(` ✗ 错误: ${error.message.substring(0, 100)}`); results.failed++; results.issues.push(`[${viewport.name}/${pageInfo.name}] 加载失败: ${error.message}`); } } await context.close(); } await browser.close(); console.log('\n\n========== 视觉测试总结 =========='); console.log(`总页面数: ${results.total}`); console.log(`成功: ${results.passed}`); console.log(`失败: ${results.failed}`); if (results.issues.length > 0) { console.log(`\n发现 ${results.issues.length} 个问题:`); for (const issue of results.issues.slice(0, 10)) { console.log(` - ${issue}`); } if (results.issues.length > 10) { console.log(` ... 还有 ${results.issues.length - 10} 个问题`); } } else { console.log('\n✓ 未发现明显视觉问题'); } console.log(`\n截图保存在: ${testDir}/`); return results; } runVisualTest().catch(console.error);