All checks were successful
Deploy play.wenpai.net / deploy (push) Successful in 3s
- 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>
143 lines
5.3 KiB
JavaScript
Executable file
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);
|
|
})();
|