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>
350 lines
14 KiB
JavaScript
Executable file
350 lines
14 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
||
// play-verify — play.wenpai.net 线上插件快捷方式验证工具
|
||
// 用法: play-verify <plugin-id> [--all] [--screenshot] [--report] [--post-forum]
|
||
// play-verify --all --browser firefox (跨浏览器测试)
|
||
// play-verify --all --browser webkit (Safari 近似)
|
||
// play-verify --all --mobile (移动端 viewport)
|
||
// 示例: play-verify wpslug
|
||
// play-verify --all
|
||
// play-verify wpslug --screenshot
|
||
// play-verify --all --report
|
||
// play-verify --all --report --post-forum
|
||
|
||
const { chromium, firefox, webkit } = require('playwright');
|
||
const { execSync } = require('child_process');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const SITE = 'https://play.wenpai.net';
|
||
const PLUGINS_JSON = path.join(process.env.HOME, 'play/plugins.json');
|
||
const SCREENSHOT_DIR = path.join(process.env.HOME, 'test-results', new Date().toISOString().slice(0, 10), 'play-verify');
|
||
|
||
const args = process.argv.slice(2);
|
||
const doAll = args.includes('--all');
|
||
const doScreenshot = args.includes('--screenshot');
|
||
const doReport = args.includes('--report');
|
||
const doForum = args.includes('--post-forum');
|
||
const doMobile = args.includes('--mobile');
|
||
const browserIdx = args.indexOf('--browser');
|
||
const browserName = browserIdx >= 0 ? args[browserIdx + 1] : 'chromium';
|
||
const pluginId = args.find(a => !a.startsWith('--') && a !== browserName);
|
||
|
||
if (!pluginId && !doAll) {
|
||
console.log('用法: play-verify <plugin-id> [--all] [--screenshot] [--report] [--post-forum]');
|
||
console.log(' play-verify wpslug 验证单个插件');
|
||
console.log(' play-verify --all 验证所有插件');
|
||
console.log(' play-verify wpslug --screenshot 验证并截图');
|
||
console.log(' play-verify --all --report 验证并生成 markdown 报告');
|
||
console.log(' play-verify --all --report --post-forum 报告并发论坛');
|
||
process.exit(0);
|
||
}
|
||
|
||
async function getPlugins() {
|
||
const data = JSON.parse(fs.readFileSync(PLUGINS_JSON, 'utf8'));
|
||
// 排除 env 类型(无插件可验证),保留 plugin/combo/featured
|
||
return data.filter(p => p.group !== 'env');
|
||
}
|
||
|
||
function extractVersionFromBlueprint(id) {
|
||
const bpPath = path.join(process.env.HOME, 'play/blueprints', `${id}.json`);
|
||
try {
|
||
const bp = JSON.parse(fs.readFileSync(bpPath, 'utf8'));
|
||
for (const step of bp.steps || []) {
|
||
if (step.step === 'installPlugin' && step.pluginData?.url) {
|
||
const m = step.pluginData.url.match(/[\w-]-([\d.]+)\.zip$/);
|
||
if (m) return m[1];
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
return null;
|
||
}
|
||
|
||
async function verifyPlugin(browser, plugin) {
|
||
const id = plugin.id || plugin;
|
||
const name = plugin.name || id;
|
||
const results = { id, name, checks: [], pass: true, timing: {} };
|
||
const t0 = Date.now();
|
||
// 自动从蓝图提取版本号(plugins.json 没有时)
|
||
if (!plugin.version) plugin.version = extractVersionFromBlueprint(id);
|
||
|
||
const viewport = doMobile
|
||
? { width: 375, height: 812 }
|
||
: { width: 1280, height: 800 };
|
||
const page = await browser.newPage({ viewport });
|
||
|
||
// 监听 JS 错误和 console.error
|
||
const jsErrors = [];
|
||
page.on('pageerror', err => jsErrors.push(err.message));
|
||
page.on('console', msg => {
|
||
if (msg.type() === 'error') jsErrors.push(msg.text());
|
||
});
|
||
|
||
try {
|
||
// 1. 快捷方式 302 重定向
|
||
const resp = await page.goto(`${SITE}/?plugin=${id}`, { waitUntil: 'load', timeout: 60000 });
|
||
const chain = resp.request().redirectedFrom();
|
||
if (chain) {
|
||
const location = page.url();
|
||
const hasFullUrl = location.includes('blueprint-url=https');
|
||
results.checks.push({ test: '302 重定向', pass: true, detail: hasFullUrl ? '完整 URL' : '相对路径' });
|
||
if (!hasFullUrl) results.pass = false;
|
||
} else {
|
||
results.checks.push({ test: '302 重定向', pass: false, detail: '无重定向' });
|
||
results.pass = false;
|
||
}
|
||
|
||
// 2. 等待 Playground 加载(计时)
|
||
const tNavDone = Date.now();
|
||
results.timing.navigationMs = tNavDone - t0;
|
||
await page.waitForTimeout(35000);
|
||
const tPlaygroundDone = Date.now();
|
||
results.timing.playgroundLoadMs = tPlaygroundDone - t0;
|
||
|
||
// 3. 检查蓝图加载错误
|
||
const outerBody = await page.textContent('body').catch(() => '');
|
||
const blueprintError = outerBody.includes('蓝图加载失败') || outerBody.includes('Blueprint file');
|
||
results.checks.push({ test: '蓝图加载', pass: !blueprintError, detail: blueprintError ? '加载失败' : 'OK' });
|
||
if (blueprintError) {
|
||
results.pass = false;
|
||
await page.close();
|
||
return results;
|
||
}
|
||
|
||
// 4. 找到 WordPress iframe
|
||
let wpFrame = null;
|
||
for (const frame of page.frames()) {
|
||
const url = frame.url();
|
||
if (url.includes('scope:') || (url.includes('wp-admin') && !url.includes('playground.html'))) {
|
||
wpFrame = frame;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!wpFrame) {
|
||
results.checks.push({ test: 'WordPress iframe', pass: false, detail: '未找到' });
|
||
results.pass = false;
|
||
await page.close();
|
||
return results;
|
||
}
|
||
|
||
results.checks.push({ test: 'WordPress iframe', pass: true, detail: wpFrame.url().substring(0, 80) });
|
||
|
||
// 5. iframe 内容检查
|
||
const wpBody = await wpFrame.textContent('body').catch(() => '');
|
||
|
||
// PHP 错误
|
||
const phpError = wpBody.includes('Fatal error') || wpBody.includes('Warning:') || wpBody.includes('Parse error');
|
||
results.checks.push({ test: '无 PHP 错误', pass: !phpError, detail: phpError ? '有错误' : 'OK' });
|
||
if (phpError) results.pass = false;
|
||
|
||
// 版本号 — 从 plugins.php 插件列表提取(最可靠来源)
|
||
const expectedVersion = plugin.version ? plugin.version.replace('v', '') : null;
|
||
if (expectedVersion) {
|
||
let versionFound = false;
|
||
let versionDetail = '未检测到';
|
||
try {
|
||
await wpFrame.evaluate(() => { window.location.href = '/wp-admin/plugins.php'; });
|
||
await wpFrame.waitForSelector('.wp-list-table', { timeout: 15000 });
|
||
const pluginRow = await wpFrame.evaluateHandle((pluginId) => {
|
||
for (const row of document.querySelectorAll('tr[data-slug]')) {
|
||
const cb = row.querySelector('input[type="checkbox"]');
|
||
if (cb && cb.value.startsWith(pluginId + '/')) return row;
|
||
}
|
||
return null;
|
||
}, id);
|
||
const isValid = await pluginRow.evaluate(el => el instanceof HTMLElement).catch(() => false);
|
||
if (isValid) {
|
||
const versionText = await pluginRow.evaluate(el => {
|
||
const ve = el.querySelector('.plugin-version-author-uri');
|
||
return ve ? ve.textContent : '';
|
||
});
|
||
const match = versionText.match(/([\d.]+)\s*版本/i) || versionText.match(/(?:Version)\s*([\d.]+)/i);
|
||
if (match) {
|
||
versionFound = match[1] === expectedVersion;
|
||
versionDetail = `plugins.php: ${match[1]}`;
|
||
} else {
|
||
versionDetail = 'plugins.php: 版本文本未匹配';
|
||
}
|
||
// 附赠:检查插件激活状态
|
||
const isActive = await pluginRow.evaluate(el => el.classList.contains('active'));
|
||
results.checks.push({ test: '插件激活', pass: isActive, detail: isActive ? '已激活' : '未激活' });
|
||
if (!isActive) results.pass = false;
|
||
} else {
|
||
versionDetail = 'plugins.php: 未找到插件行';
|
||
}
|
||
|
||
// 检查 wp-admin 通知栏是否有错误/警告
|
||
const adminNotices = await wpFrame.evaluate(() => {
|
||
const notices = [];
|
||
for (const el of document.querySelectorAll('.notice-error, .notice-warning, .error')) {
|
||
const text = el.textContent.trim();
|
||
if (text) notices.push(text.substring(0, 120));
|
||
}
|
||
return notices;
|
||
}).catch(() => []);
|
||
if (adminNotices.length > 0) {
|
||
results.checks.push({ test: '管理通知', pass: false, detail: adminNotices.join(' | ') });
|
||
results.pass = false;
|
||
} else {
|
||
results.checks.push({ test: '管理通知', pass: true, detail: '无错误/警告通知' });
|
||
}
|
||
} catch (_) {
|
||
// Fallback: 回退到 body 全文搜索
|
||
const wpBodyFallback = await wpFrame.textContent('body').catch(() => '');
|
||
versionFound = wpBodyFallback.includes(expectedVersion);
|
||
versionDetail = versionFound ? `fallback: ${expectedVersion}` : 'fallback: 未检测到';
|
||
}
|
||
results.checks.push({ test: `版本号 ${expectedVersion}`, pass: versionFound, detail: versionDetail });
|
||
if (!versionFound) results.pass = false;
|
||
}
|
||
|
||
// 中文界面
|
||
const isChinese = wpBody.includes('设置') || wpBody.includes('仪表盘') || wpBody.includes('插件') || wpBody.includes('文章');
|
||
results.checks.push({ test: '中文界面', pass: isChinese, detail: isChinese ? 'OK' : '未检测到中文' });
|
||
|
||
// 截图
|
||
if (doScreenshot) {
|
||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, `${id}.png`), fullPage: true });
|
||
results.checks.push({ test: '截图', pass: true, detail: `${SCREENSHOT_DIR}/${id}.png` });
|
||
}
|
||
|
||
// JS 错误汇报
|
||
// 过滤掉 Playground 内部的已知噪音
|
||
const realErrors = jsErrors.filter(e =>
|
||
!e.includes('service-worker') &&
|
||
!e.includes('Failed to fetch') &&
|
||
!e.includes('net::ERR_')
|
||
);
|
||
if (realErrors.length > 0) {
|
||
results.checks.push({ test: 'JS 错误', pass: false, detail: realErrors.slice(0, 3).join(' | ') });
|
||
results.pass = false;
|
||
} else {
|
||
results.checks.push({ test: 'JS 错误', pass: true, detail: `无 (${jsErrors.length} 条已知噪音已过滤)` });
|
||
}
|
||
|
||
} catch (err) {
|
||
results.checks.push({ test: '运行异常', pass: false, detail: err.message.substring(0, 100) });
|
||
results.pass = false;
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
function generateReport(allResults) {
|
||
const now = new Date();
|
||
const dateStr = now.toISOString().slice(0, 10);
|
||
const timeStr = now.toTimeString().slice(0, 5);
|
||
const passed = allResults.filter(r => r.pass).length;
|
||
const failed = allResults.filter(r => !r.pass).length;
|
||
const total = allResults.length;
|
||
|
||
let md = `# play.wenpai.net 验证报告\n\n`;
|
||
md += `**日期**: ${dateStr} ${timeStr} \n`;
|
||
md += `**总览**: ${passed} 通过 / ${failed} 失败 / ${total} 总计\n\n`;
|
||
|
||
md += `## 逐插件检查\n\n`;
|
||
for (const r of allResults) {
|
||
const icon = r.pass ? '✅' : '❌';
|
||
md += `### ${icon} ${r.name} (\`${r.id}\`)\n\n`;
|
||
for (const c of r.checks) {
|
||
md += `- [${c.pass ? 'x' : ' '}] **${c.test}**: ${c.detail}\n`;
|
||
}
|
||
md += `\n`;
|
||
}
|
||
|
||
if (failed > 0) {
|
||
md += `## 失败汇总\n\n`;
|
||
for (const r of allResults.filter(r => !r.pass)) {
|
||
const failedChecks = r.checks.filter(c => !c.pass).map(c => c.test).join(', ');
|
||
md += `- **${r.name}**: ${failedChecks}\n`;
|
||
}
|
||
md += `\n`;
|
||
}
|
||
|
||
return md;
|
||
}
|
||
|
||
(async () => {
|
||
const engines = { chromium, firefox, webkit };
|
||
const engine = engines[browserName];
|
||
if (!engine) {
|
||
console.error(`不支持的浏览器: ${browserName},可选: chromium, firefox, webkit`);
|
||
process.exit(1);
|
||
}
|
||
console.log(`浏览器: ${browserName}${doMobile ? ' (移动端)' : ''}`);
|
||
|
||
const launchOpts = { headless: true };
|
||
const browser = await engine.launch(launchOpts);
|
||
const plugins = doAll ? await getPlugins() : [pluginId];
|
||
const allResults = [];
|
||
|
||
for (const p of plugins) {
|
||
const plugin = typeof p === 'string' ? { id: p, name: p } : p;
|
||
process.stdout.write(`\n验证 ${plugin.name || plugin.id} ... `);
|
||
|
||
const result = await verifyPlugin(browser, plugin);
|
||
allResults.push(result);
|
||
|
||
console.log(result.pass ? 'PASS' : 'FAIL');
|
||
for (const c of result.checks) {
|
||
console.log(` [${c.pass ? '✓' : '✗'}] ${c.test}: ${c.detail}`);
|
||
}
|
||
if (result.timing.playgroundLoadMs) {
|
||
console.log(` [⏱] 加载耗时: ${(result.timing.playgroundLoadMs / 1000).toFixed(1)}s (导航 ${(result.timing.navigationMs / 1000).toFixed(1)}s)`);
|
||
}
|
||
}
|
||
|
||
await browser.close();
|
||
|
||
// 汇总
|
||
const passed = allResults.filter(r => r.pass).length;
|
||
const failed = allResults.filter(r => !r.pass).length;
|
||
console.log(`\n=== 汇总: ${passed} 通过, ${failed} 失败, 共 ${allResults.length} ===`);
|
||
|
||
if (doScreenshot) {
|
||
console.log(`截图目录: ${SCREENSHOT_DIR}`);
|
||
}
|
||
|
||
// 保存结果
|
||
if (doAll || doScreenshot || doReport || doForum) {
|
||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||
fs.writeFileSync(path.join(SCREENSHOT_DIR, 'results.json'), JSON.stringify(allResults, null, 2));
|
||
}
|
||
|
||
// 保存性能基线
|
||
if (doAll) {
|
||
const perfPath = path.join(SCREENSHOT_DIR, 'performance-baseline.json');
|
||
const perfData = allResults.map(r => ({
|
||
id: r.id, name: r.name, date: new Date().toISOString(),
|
||
navigationMs: r.timing.navigationMs || null,
|
||
playgroundLoadMs: r.timing.playgroundLoadMs || null
|
||
}));
|
||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||
fs.writeFileSync(perfPath, JSON.stringify(perfData, null, 2));
|
||
console.log(`性能基线: ${perfPath}`);
|
||
}
|
||
|
||
// 生成 markdown 报告
|
||
if (doReport || doForum) {
|
||
const report = generateReport(allResults);
|
||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||
const reportPath = path.join(SCREENSHOT_DIR, 'verify-report.md');
|
||
fs.writeFileSync(reportPath, report);
|
||
console.log(`报告: ${reportPath}`);
|
||
|
||
if (doForum) {
|
||
try {
|
||
const title = `play.wenpai.net 验证报告 ${new Date().toISOString().slice(0, 10)}`;
|
||
execSync(`forum-cli post --title "${title}" --body "${report.replace(/"/g, '\\"')}"`, { stdio: 'inherit' });
|
||
console.log('已发布到论坛');
|
||
} catch (e) {
|
||
console.error('论坛发布失败:', e.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
process.exit(failed > 0 ? 1 : 0);
|
||
})();
|