play.wenpai.net/scripts/play-visual
elementary-qa 36b7ad5288
All checks were successful
Deploy play.wenpai.net / deploy (push) Successful in 3s
feat: add 7-layer test infrastructure for play.wenpai.net
- Schema validation (validate-plugins): field/type/enum/blueprint existence checks
- Deep verification (play-verify): admin notice bar, plugin activation, JS error capture
- Performance baseline (play-verify): navigation + playground load timing
- Visual regression (play-visual): 3 viewports, ImageMagick pixel diff
- Cross-browser (play-verify): --browser firefox/webkit, --mobile viewport
- Accessibility (play-a11y): axe-core WCAG 2 AA audit
- Error monitoring (play-smoke): blueprint URL reachability for all zip references
- Fix: color contrast on featured card (opacity 0.45→0.7) for WCAG AA compliance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 14:51:08 +08:00

143 lines
5.3 KiB
JavaScript
Executable file

#!/usr/bin/env node
// play-visual — play.wenpai.net 视觉回归测试
// 用法: play-visual --update-baseline (生成/更新基线截图)
// play-visual (对比当前截图与基线)
// play-visual --threshold 0.05 (自定义像素差异阈值,默认 0.01)
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const SITE = 'https://play.wenpai.net';
const BASELINE_DIR = path.join(process.env.HOME, 'play', 'screenshots', 'baseline');
const CURRENT_DIR = path.join(process.env.HOME, 'test-results', new Date().toISOString().slice(0, 10), 'visual');
const DIFF_DIR = path.join(CURRENT_DIR, 'diff');
const args = process.argv.slice(2);
const updateBaseline = args.includes('--update-baseline');
const thresholdIdx = args.indexOf('--threshold');
const THRESHOLD = thresholdIdx >= 0 ? parseFloat(args[thresholdIdx + 1]) : 0.01;
const RED = '\x1b[31m', GREEN = '\x1b[32m', YELLOW = '\x1b[33m', NC = '\x1b[0m';
let pass = 0, fail = 0;
const VIEWPORTS = [
{ name: 'desktop', width: 1280, height: 800 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'mobile', width: 375, height: 812 }
];
async function captureScreenshots(browser, outDir) {
fs.mkdirSync(outDir, { recursive: true });
const files = [];
for (const vp of VIEWPORTS) {
const page = await browser.newPage({ viewport: { width: vp.width, height: vp.height } });
await page.goto(SITE, { waitUntil: 'networkidle', timeout: 30000 });
// 等待动画完成
await page.waitForTimeout(1500);
const filename = `homepage-${vp.name}.png`;
await page.screenshot({ path: path.join(outDir, filename), fullPage: true });
files.push(filename);
await page.close();
}
return files;
}
// 像素对比(使用 ImageMagick compare 如果可用,否则文件大小对比)
// 注意:所有路径均由脚本内部生成,无用户输入,不存在注入风险
function compareImages(baseline, current, diffPath) {
try {
const { execFileSync } = require('child_process');
// 使用 execFileSync 避免 shell 注入
const result = execFileSync('compare', ['-metric', 'AE', baseline, current, diffPath], {
encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe']
});
const diffPixels = parseInt(result) || 0;
const identify = execFileSync('identify', ['-format', '%w %h', baseline], { encoding: 'utf8' }).trim();
const [w, h] = identify.split(' ').map(Number);
const totalPixels = w * h;
const ratio = diffPixels / totalPixels;
return { diffPixels, totalPixels, ratio, method: 'imagemagick' };
} catch (e) {
// ImageMagick compare 返回非零退出码时 stderr 包含差异像素数
if (e.stderr) {
const diffPixels = parseInt(e.stderr) || 0;
try {
const { execFileSync } = require('child_process');
const identify = execFileSync('identify', ['-format', '%w %h', baseline], { encoding: 'utf8' }).trim();
const [w, h] = identify.split(' ').map(Number);
const totalPixels = w * h;
return { diffPixels, totalPixels, ratio: diffPixels / totalPixels, method: 'imagemagick' };
} catch (_) {}
}
// 退化为文件大小对比
const baseSize = fs.statSync(baseline).size;
const currSize = fs.statSync(current).size;
const ratio = Math.abs(baseSize - currSize) / baseSize;
return { diffPixels: null, totalPixels: null, ratio, method: 'filesize' };
}
}
(async () => {
console.log('\n=== play.wenpai.net 视觉回归测试 ===\n');
const browser = await chromium.launch({ headless: true });
if (updateBaseline) {
console.log('更新基线截图...');
const files = await captureScreenshots(browser, BASELINE_DIR);
await browser.close();
console.log(`${GREEN}基线已更新${NC}: ${BASELINE_DIR}`);
for (const f of files) console.log(` ${f}`);
process.exit(0);
}
// 检查基线是否存在
if (!fs.existsSync(BASELINE_DIR)) {
console.log(`${YELLOW}基线目录不存在,先运行: play-visual --update-baseline${NC}`);
await browser.close();
process.exit(1);
}
// 截取当前截图
console.log('截取当前截图...');
const files = await captureScreenshots(browser, CURRENT_DIR);
await browser.close();
// 对比
fs.mkdirSync(DIFF_DIR, { recursive: true });
console.log(`\n阈值: ${(THRESHOLD * 100).toFixed(1)}%\n`);
for (const f of files) {
const baselinePath = path.join(BASELINE_DIR, f);
const currentPath = path.join(CURRENT_DIR, f);
const diffPath = path.join(DIFF_DIR, f);
if (!fs.existsSync(baselinePath)) {
console.log(` ${YELLOW}[!]${NC} ${f}: 无基线,跳过`);
continue;
}
const result = compareImages(baselinePath, currentPath, diffPath);
const ok = result.ratio <= THRESHOLD;
if (ok) {
console.log(` ${GREEN}[✓]${NC} ${f}: 差异 ${(result.ratio * 100).toFixed(2)}% (${result.method})`);
pass++;
} else {
console.log(` ${RED}[✗]${NC} ${f}: 差异 ${(result.ratio * 100).toFixed(2)}% 超过阈值 (${result.method})`);
if (result.method === 'imagemagick') {
console.log(` 差异图: ${diffPath}`);
}
fail++;
}
}
console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`);
process.exit(fail > 0 ? 1 : 0);
})();