Compare commits
10 commits
6efd64221e
...
a2e193dbdd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e193dbdd | ||
|
|
475aa37b17 | ||
|
|
8f258e2bcc | ||
|
|
196fc516a8 | ||
|
|
504315bcf2 | ||
|
|
4526a24372 | ||
|
|
fad50aa160 | ||
|
|
a0f3c28a8c | ||
|
|
6c8717a27a | ||
|
|
22093b4499 |
19 changed files with 956 additions and 13 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -7,8 +7,13 @@
|
|||
!backstop.json
|
||||
!pa11y.json
|
||||
!acceptance-criteria.json
|
||||
!playwright.config.js
|
||||
|
||||
# 允许跟踪的目录
|
||||
!tests/
|
||||
!tests/**
|
||||
!play/
|
||||
!play/**
|
||||
!blueprints/
|
||||
!blueprints/**
|
||||
!scripts/
|
||||
|
|
|
|||
92
Justfile
92
Justfile
|
|
@ -53,6 +53,57 @@ snapshot name:
|
|||
|
||||
# ─── 完整验收流程 ──────────────────────────────────
|
||||
|
||||
# 一键拉取 + 验收(从 Forgejo 拉最新 release → 启动 Playground → 跑验收 → 导入趋势)
|
||||
fetch-and-test repo name="":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
PLUGIN_NAME="${2:-$(basename "{{repo}}")}"
|
||||
echo "=== 拉取 {{repo}} 最新 release ==="
|
||||
bash scripts/fetch-release.sh "{{repo}}"
|
||||
ZIP=$(ls -t ~/下载/${PLUGIN_NAME}-*.zip 2>/dev/null | head -1)
|
||||
[ -z "$ZIP" ] && { echo "未找到 zip 文件"; exit 1; }
|
||||
echo "=== 准备 Playground ==="
|
||||
# 停掉旧的 Playground 和 HTTP server
|
||||
pkill -f "wp-playground" 2>/dev/null || true
|
||||
pkill -f "python3 -m http.server 8888" 2>/dev/null || true
|
||||
sleep 2
|
||||
# 启动 HTTP server 服务 zip
|
||||
cp "$ZIP" /tmp/${PLUGIN_NAME}.zip
|
||||
cd /tmp && nohup python3 -m http.server 8888 > /dev/null 2>&1 &
|
||||
HTTP_PID=$!
|
||||
sleep 1
|
||||
# 生成临时 Blueprint
|
||||
cat > /tmp/${PLUGIN_NAME}-blueprint.json << BPEOF
|
||||
{
|
||||
"\$schema": "https://playground.wordpress.net/blueprint-schema.json",
|
||||
"landingPage": "/wp-admin/plugins.php",
|
||||
"preferredVersions": { "wp": "{{pg_wp}}", "php": "{{pg_php}}" },
|
||||
"steps": [
|
||||
{ "step": "setSiteOptions", "options": { "blogname": "验收测试站", "WPLANG": "zh_CN", "timezone_string": "Asia/Shanghai", "date_format": "Y-m-d", "time_format": "H:i" } },
|
||||
{ "step": "login", "username": "admin", "password": "password" },
|
||||
{ "step": "installPlugin", "pluginData": { "resource": "url", "url": "http://127.0.0.1:8888/${PLUGIN_NAME}.zip" } }
|
||||
]
|
||||
}
|
||||
BPEOF
|
||||
# 启动 Playground
|
||||
nohup npx @wp-playground/cli@{{pg_ver}} server --port=9400 --login --blueprint=/tmp/${PLUGIN_NAME}-blueprint.json > /tmp/playground.log 2>&1 &
|
||||
PG_PID=$!
|
||||
echo "等待 Playground 启动..."
|
||||
for i in $(seq 1 30); do
|
||||
curl -s http://localhost:9400/ -o /dev/null && break
|
||||
sleep 1
|
||||
done
|
||||
echo "=== 开始验收 ==="
|
||||
just test-plugin "$PLUGIN_NAME"
|
||||
# 导入趋势数据库
|
||||
RESULTS="$HOME/test-results/$(date +%Y-%m-%d)/${PLUGIN_NAME}"
|
||||
if [ -f "$RESULTS/verdict.json" ]; then
|
||||
node scripts/trend-tracker.js import "$RESULTS/verdict.json" 2>/dev/null && echo "[trend] 已导入趋势数据库" || true
|
||||
fi
|
||||
# 清理
|
||||
kill $HTTP_PID 2>/dev/null || true
|
||||
echo "=== 全流程完成 ==="
|
||||
|
||||
# 完整验收(CLI + Playwright + 视觉回归 + 报告)
|
||||
test-plugin name:
|
||||
#!/usr/bin/env bash
|
||||
|
|
@ -75,10 +126,24 @@ test-plugin name:
|
|||
# Playwright 自动化
|
||||
node scripts/playwright/screenshots.js "{{name}}" "{{site}}" "$RESULTS/screenshots" || true
|
||||
node scripts/playwright/security-scan.js "{{site}}" "$RESULTS/security" || true
|
||||
# 视觉回归
|
||||
# 等待 Chromium 进程释放,避免与 BackstopJS 资源竞争
|
||||
sleep 2
|
||||
pkill -f "chromium.*--headless" 2>/dev/null || true
|
||||
sleep 1
|
||||
# 视觉回归(自动选择基线)
|
||||
bash scripts/backstop-baseline.sh auto "{{name}}" "unknown" 2>/dev/null || true
|
||||
just visual-test || true
|
||||
# i18n 验收
|
||||
just i18n-test "{{name}}" || true
|
||||
# PHPCS 静态分析(如果有源码)
|
||||
PLUGIN_SRC="/tmp/wpmind-review/wpmind-src/{{name}}"
|
||||
if [ -d "$PLUGIN_SRC" ]; then
|
||||
bash scripts/phpcs-scan.sh "$PLUGIN_SRC" "$RESULTS/phpcs" || true
|
||||
fi
|
||||
# Playwright 功能回归测试
|
||||
export TEST_OUTPUT="$RESULTS/playwright"
|
||||
mkdir -p "$TEST_OUTPUT"
|
||||
npx playwright test --config=playwright.config.js 2>&1 | tee "$TEST_OUTPUT/output.log" || true
|
||||
# 生成汇总报告
|
||||
node scripts/generate-report.js "{{name}}" "$RESULTS"
|
||||
echo "=== 验收完成: $RESULTS/report.md ==="
|
||||
|
|
@ -180,6 +245,31 @@ visual-baseline:
|
|||
visual-test:
|
||||
backstop test --config=backstop.json
|
||||
|
||||
# 基线版本管理
|
||||
baseline-save plugin version:
|
||||
bash scripts/backstop-baseline.sh save {{plugin}} {{version}}
|
||||
|
||||
baseline-use plugin version:
|
||||
bash scripts/backstop-baseline.sh use {{plugin}} {{version}}
|
||||
|
||||
baseline-auto plugin version:
|
||||
bash scripts/backstop-baseline.sh auto {{plugin}} {{version}}
|
||||
|
||||
baseline-list plugin="":
|
||||
bash scripts/backstop-baseline.sh list {{plugin}}
|
||||
|
||||
# PHPCS 静态分析
|
||||
phpcs-scan name src-dir:
|
||||
bash scripts/phpcs-scan.sh {{src-dir}} "$HOME/test-results/{{date}}/{{name}}/phpcs"
|
||||
|
||||
# Playwright 功能回归测试
|
||||
pw-test name:
|
||||
#!/usr/bin/env bash
|
||||
export TEST_OUTPUT="$HOME/test-results/{{date}}/{{name}}/playwright"
|
||||
mkdir -p "$TEST_OUTPUT"
|
||||
npx playwright test --config=playwright.config.js 2>&1 | tee "$TEST_OUTPUT/output.log"
|
||||
echo "[pw-test] 结果: $TEST_OUTPUT"
|
||||
|
||||
# 截图对比(两个目录)
|
||||
screenshot-diff name actual expected:
|
||||
#!/usr/bin/env bash
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@
|
|||
"security": {
|
||||
"high": { "pass": 0, "warn": 0, "description": "高危漏洞数 (0=pass, >0=fail)" },
|
||||
"medium": { "pass": 0, "warn": 2, "description": "中危漏洞数" },
|
||||
"low": { "pass": 3, "warn": 5, "description": "低危漏洞数" }
|
||||
"low": { "pass": 3, "warn": 5, "description": "低危漏洞数" },
|
||||
"knownWpCoreLeaks": ["readme.html", "xmlrpc", "debug.log"],
|
||||
"_leakNote": "WordPress 核心已知信息泄露,不计入插件安全评分"
|
||||
},
|
||||
|
||||
"accessibility": {
|
||||
|
|
@ -37,8 +39,13 @@
|
|||
"diffPercent": { "pass": 0.5, "warn": 2.0, "description": "视觉差异百分比" }
|
||||
},
|
||||
|
||||
"phpcs": {
|
||||
"high": { "pass": 0, "warn": 0, "description": "高危 (SQL注入/CSRF, 0=pass, >0=fail)" },
|
||||
"medium": { "pass": 5, "warn": 10, "description": "中危 (输出未转义)" }
|
||||
},
|
||||
|
||||
"overall": {
|
||||
"passThreshold": 7,
|
||||
"description": "9 维度中至少 N 个通过才算整体通过"
|
||||
"passThreshold": 8,
|
||||
"description": "10 维度中至少 N 个通过才算整体通过"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
blueprints/wpmind.json
Normal file
15
blueprints/wpmind.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
|
||||
"landingPage": "/wp-admin/admin.php?page=wpmind",
|
||||
"preferredVersions": {
|
||||
"php": "8.3",
|
||||
"wp": "latest"
|
||||
},
|
||||
"steps": [
|
||||
{ "step": "setSiteLanguage", "language": "zh_CN" },
|
||||
{ "step": "setSiteOptions", "options": { "blogname": "WPMind 体验站", "timezone_string": "Asia/Shanghai", "date_format": "Y-m-d", "time_format": "H:i" } },
|
||||
{ "step": "installPlugin", "pluginData": { "resource": "url", "url": "https://feicode.com/WenPai-org/wpmind/releases/download/v0.11.3/wpmind-0.11.3.zip" } },
|
||||
{ "step": "activatePlugin", "pluginPath": "wpmind/wpmind.php" },
|
||||
{ "step": "login", "username": "admin", "password": "password" }
|
||||
]
|
||||
}
|
||||
10
play/blueprints/starter.json
Normal file
10
play/blueprints/starter.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
|
||||
"landingPage": "/",
|
||||
"preferredVersions": { "php": "8.3", "wp": "latest" },
|
||||
"steps": [
|
||||
{ "step": "setSiteLanguage", "language": "zh_CN" },
|
||||
{ "step": "setSiteOptions", "options": { "blogname": "文派体验站", "timezone_string": "Asia/Shanghai", "date_format": "Y-m-d", "time_format": "H:i" } },
|
||||
{ "step": "login", "username": "admin", "password": "password" }
|
||||
]
|
||||
}
|
||||
12
play/blueprints/wpmind.json
Normal file
12
play/blueprints/wpmind.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
|
||||
"landingPage": "/wp-admin/admin.php?page=wpmind",
|
||||
"preferredVersions": { "php": "8.3", "wp": "latest" },
|
||||
"steps": [
|
||||
{ "step": "setSiteLanguage", "language": "zh_CN" },
|
||||
{ "step": "setSiteOptions", "options": { "blogname": "WPMind 体验站", "timezone_string": "Asia/Shanghai", "date_format": "Y-m-d", "time_format": "H:i" } },
|
||||
{ "step": "installPlugin", "pluginData": { "resource": "url", "url": "https://play.wenpai.net/plugins/wpmind-0.11.3.zip" } },
|
||||
{ "step": "activatePlugin", "pluginPath": "wpmind/wpmind.php" },
|
||||
{ "step": "login", "username": "admin", "password": "password" }
|
||||
]
|
||||
}
|
||||
98
play/index.html
Normal file
98
play/index.html
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>文派体验场 — play.wenpai.net</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f0f2f5; color: #1d2327; min-height: 100vh; }
|
||||
.header { background: #1e1e1e; color: #fff; padding: 2rem 1rem; text-align: center; }
|
||||
.header h1 { font-size: 1.8rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||
.header p { color: #a0a0a0; font-size: 0.95rem; }
|
||||
.container { max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
|
||||
.card { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); transition: box-shadow 0.2s; }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
|
||||
.card-body { padding: 1.5rem; }
|
||||
.card-tag { display: inline-block; font-size: 0.75rem; padding: 2px 8px; border-radius: 4px; background: #e8f0fe; color: #1a73e8; margin-bottom: 0.75rem; }
|
||||
.card-tag.core { background: #fef3e0; color: #e65100; }
|
||||
.card h2 { font-size: 1.2rem; margin-bottom: 0.5rem; }
|
||||
.card p { font-size: 0.9rem; color: #606770; line-height: 1.6; margin-bottom: 1rem; }
|
||||
.card .meta { font-size: 0.8rem; color: #999; margin-bottom: 1rem; }
|
||||
.btn { display: inline-block; padding: 0.6rem 1.5rem; border-radius: 8px; text-decoration: none; font-size: 0.9rem; font-weight: 500; transition: background 0.2s; }
|
||||
.btn-primary { background: #1e1e1e; color: #fff; }
|
||||
.btn-primary:hover { background: #333; }
|
||||
.btn-secondary { background: #f0f2f5; color: #1d2327; margin-left: 0.5rem; }
|
||||
.btn-secondary:hover { background: #e4e6e9; }
|
||||
.footer { text-align: center; padding: 2rem 1rem; color: #999; font-size: 0.85rem; }
|
||||
.note { background: #fff8e1; border-left: 3px solid #ffc107; padding: 1rem 1.25rem; border-radius: 0 8px 8px 0; margin-bottom: 2rem; font-size: 0.9rem; color: #665500; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>文派体验场</h1>
|
||||
<p>在浏览器中即时体验文派开源插件,无需安装</p>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="note">
|
||||
所有体验环境均为临时沙盒,数据不会保存。AI 相关功能需要配置 API Key 后才能使用。
|
||||
</div>
|
||||
<div class="grid" id="plugins"></div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://wordpress.github.io/wordpress-playground/" style="color:#666">WordPress Playground</a> · <a href="https://wenpai.net" style="color:#666">wenpai.net</a></p>
|
||||
</div>
|
||||
<script>
|
||||
// 插件注册表 — 新增插件只需在这里加一条
|
||||
const PLAYGROUND_BASE = window.location.origin;
|
||||
const plugins = [
|
||||
{
|
||||
id: 'wpmind',
|
||||
name: 'WPMind',
|
||||
tag: 'AI',
|
||||
tagClass: '',
|
||||
version: 'v0.11.3',
|
||||
desc: 'WordPress AI 内容助手。智能路由多家 AI 服务商,支持内容生成、SEO 优化、图片处理、预算控制等功能。',
|
||||
blueprint: '/blueprints/wpmind.json',
|
||||
repo: 'https://feicode.com/WenPai-org/wpmind',
|
||||
},
|
||||
{
|
||||
id: 'starter',
|
||||
name: '空白中文环境',
|
||||
tag: '基础',
|
||||
tagClass: 'core',
|
||||
version: 'WP latest',
|
||||
desc: '干净的中文 WordPress 环境,预设简体中文语言和上海时区。适合测试主题或手动安装插件。',
|
||||
blueprint: '/blueprints/starter.json',
|
||||
repo: '',
|
||||
},
|
||||
];
|
||||
|
||||
function playUrl(bp) {
|
||||
// 自托管模式:直接加载 Blueprint
|
||||
// 远程模式(备用):通过 playground.wordpress.net 加载
|
||||
const selfHosted = document.querySelector('meta[name="playground-mode"]');
|
||||
if (selfHosted) {
|
||||
return `/?blueprint-url=${bp}`;
|
||||
}
|
||||
return `https://playground.wordpress.net/?blueprint-url=${PLAYGROUND_BASE}${bp}`;
|
||||
}
|
||||
|
||||
const grid = document.getElementById('plugins');
|
||||
plugins.forEach(p => {
|
||||
grid.innerHTML += `
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<span class="card-tag ${p.tagClass}">${p.tag}</span>
|
||||
<h2>${p.name}</h2>
|
||||
<div class="meta">${p.version}</div>
|
||||
<p>${p.desc}</p>
|
||||
<a class="btn btn-primary" href="${playUrl(p.blueprint)}" target="_blank">立即体验</a>
|
||||
${p.repo ? `<a class="btn btn-secondary" href="${p.repo}" target="_blank">源码</a>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
play/plugins/wpmind-0.11.3.zip
Normal file
BIN
play/plugins/wpmind-0.11.3.zip
Normal file
Binary file not shown.
25
playwright.config.js
Normal file
25
playwright.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// @ts-check
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 60000,
|
||||
retries: 1,
|
||||
workers: 2,
|
||||
use: {
|
||||
baseURL: process.env.WP_SITE || 'http://127.0.0.1:9400',
|
||||
headless: true,
|
||||
launchOptions: {
|
||||
executablePath: process.env.CHROMIUM_PATH || `${process.env.HOME}/.cache/ms-playwright/chromium-1212/chrome-linux/chrome`,
|
||||
args: ['--no-sandbox', '--disable-gpu'],
|
||||
},
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
outputDir: process.env.TEST_OUTPUT || './test-results-pw',
|
||||
reporter: [
|
||||
['list'],
|
||||
['json', { outputFile: `${process.env.TEST_OUTPUT || './test-results-pw'}/results.json` }],
|
||||
['html', { outputFolder: `${process.env.TEST_OUTPUT ? process.env.TEST_OUTPUT + '-html' : './test-results-pw-html'}`, open: 'never' }],
|
||||
],
|
||||
});
|
||||
137
scripts/backstop-baseline.sh
Executable file
137
scripts/backstop-baseline.sh
Executable file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env bash
|
||||
# BackstopJS 基线版本管理
|
||||
# 用法:
|
||||
# backstop-baseline.sh save <plugin> <version> — 保存当前参考图为指定版本基线
|
||||
# backstop-baseline.sh use <plugin> <version> — 切换到指定版本基线
|
||||
# backstop-baseline.sh latest <plugin> — 切换到最新版本基线
|
||||
# backstop-baseline.sh list [plugin] — 列出可用基线
|
||||
# backstop-baseline.sh auto <plugin> <version> — 自动选择: 有上一版本用上一版本,没有则用最新
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASELINES_DIR="$HOME/backstop_data/baselines"
|
||||
REF_DIR="$HOME/backstop_data/bitmaps_reference"
|
||||
|
||||
mkdir -p "$BASELINES_DIR"
|
||||
|
||||
cmd="${1:-help}"
|
||||
plugin="${2:-}"
|
||||
version="${3:-}"
|
||||
|
||||
die() { echo "[baseline] ERROR: $1" >&2; exit 1; }
|
||||
|
||||
# 版本排序(semver 友好)
|
||||
sorted_versions() {
|
||||
local p="$1"
|
||||
ls -1 "$BASELINES_DIR" 2>/dev/null \
|
||||
| grep "^${p}-" \
|
||||
| sed "s/^${p}-//" \
|
||||
| sort -V
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
save)
|
||||
[ -z "$plugin" ] && die "用法: $0 save <plugin> <version>"
|
||||
[ -z "$version" ] && die "用法: $0 save <plugin> <version>"
|
||||
|
||||
# 确认有参考图可保存
|
||||
src="$REF_DIR"
|
||||
[ -L "$src" ] && src="$(readlink -f "$src")"
|
||||
count=$(find "$src" -maxdepth 1 -name "*.png" 2>/dev/null | wc -l)
|
||||
[ "$count" -eq 0 ] && die "bitmaps_reference 中没有 PNG 文件"
|
||||
|
||||
dest="$BASELINES_DIR/${plugin}-${version}"
|
||||
mkdir -p "$dest"
|
||||
cp "$src"/*.png "$dest/"
|
||||
echo "[baseline] 已保存 ${plugin} v${version} 基线 (${count} 张图片) → $dest"
|
||||
;;
|
||||
|
||||
use)
|
||||
[ -z "$plugin" ] && die "用法: $0 use <plugin> <version>"
|
||||
[ -z "$version" ] && die "用法: $0 use <plugin> <version>"
|
||||
|
||||
target="$BASELINES_DIR/${plugin}-${version}"
|
||||
[ ! -d "$target" ] && die "基线不存在: $target"
|
||||
|
||||
# 如果当前 bitmaps_reference 是真实目录且有内容,先备份
|
||||
if [ -d "$REF_DIR" ] && [ ! -L "$REF_DIR" ]; then
|
||||
count=$(find "$REF_DIR" -maxdepth 1 -name "*.png" 2>/dev/null | wc -l)
|
||||
if [ "$count" -gt 0 ]; then
|
||||
backup="$REF_DIR.backup.$(date +%Y%m%d%H%M%S)"
|
||||
mv "$REF_DIR" "$backup"
|
||||
echo "[baseline] 已备份原参考图 → $backup"
|
||||
else
|
||||
rm -rf "$REF_DIR"
|
||||
fi
|
||||
elif [ -L "$REF_DIR" ]; then
|
||||
rm "$REF_DIR"
|
||||
fi
|
||||
|
||||
ln -sf "$target" "$REF_DIR"
|
||||
echo "[baseline] 已切换到 ${plugin} v${version} 基线"
|
||||
;;
|
||||
|
||||
latest)
|
||||
[ -z "$plugin" ] && die "用法: $0 latest <plugin>"
|
||||
latest_ver=$(sorted_versions "$plugin" | tail -1)
|
||||
[ -z "$latest_ver" ] && die "没有找到 ${plugin} 的基线"
|
||||
exec "$0" use "$plugin" "$latest_ver"
|
||||
;;
|
||||
|
||||
auto)
|
||||
[ -z "$plugin" ] && die "用法: $0 auto <plugin> <new-version>"
|
||||
[ -z "$version" ] && die "用法: $0 auto <plugin> <new-version>"
|
||||
|
||||
# 找到比当前版本小的最新版本
|
||||
prev_ver=$(sorted_versions "$plugin" | grep -v "^${version}$" | tail -1 || true)
|
||||
|
||||
if [ -n "$prev_ver" ]; then
|
||||
echo "[baseline] 找到上一版本基线: ${plugin} v${prev_ver}"
|
||||
exec "$0" use "$plugin" "$prev_ver"
|
||||
else
|
||||
# 没有历史基线,尝试用最新的
|
||||
any_ver=$(sorted_versions "$plugin" | tail -1 || true)
|
||||
if [ -n "$any_ver" ]; then
|
||||
echo "[baseline] 无上一版本,使用最新基线: ${plugin} v${any_ver}"
|
||||
exec "$0" use "$plugin" "$any_ver"
|
||||
else
|
||||
echo "[baseline] 无可用基线,将以当前参考图运行(首次验收)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
list)
|
||||
if [ -n "$plugin" ]; then
|
||||
echo "[baseline] ${plugin} 可用基线:"
|
||||
sorted_versions "$plugin" | while read -r v; do
|
||||
count=$(find "$BASELINES_DIR/${plugin}-${v}" -name "*.png" | wc -l)
|
||||
echo " v${v} (${count} 张)"
|
||||
done
|
||||
else
|
||||
echo "[baseline] 所有基线:"
|
||||
ls -1 "$BASELINES_DIR" 2>/dev/null | while read -r d; do
|
||||
count=$(find "$BASELINES_DIR/$d" -name "*.png" | wc -l)
|
||||
echo " $d (${count} 张)"
|
||||
done
|
||||
fi
|
||||
|
||||
# 显示当前激活的基线
|
||||
if [ -L "$REF_DIR" ]; then
|
||||
echo "[baseline] 当前激活: $(readlink "$REF_DIR" | xargs basename)"
|
||||
else
|
||||
echo "[baseline] 当前激活: 本地目录 (未关联版本)"
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "BackstopJS 基线版本管理"
|
||||
echo ""
|
||||
echo "用法:"
|
||||
echo " $0 save <plugin> <version> 保存当前参考图为版本基线"
|
||||
echo " $0 use <plugin> <version> 切换到指定版本基线"
|
||||
echo " $0 latest <plugin> 切换到最新版本基线"
|
||||
echo " $0 auto <plugin> <version> 自动选择上一版本基线"
|
||||
echo " $0 list [plugin] 列出可用基线"
|
||||
;;
|
||||
esac
|
||||
|
|
@ -82,6 +82,8 @@ const sections = [];
|
|||
let passCount = 0;
|
||||
let warnCount = 0;
|
||||
let failCount = 0;
|
||||
const dimensions = []; // verdict.json 结构化数据
|
||||
const issues = []; // 所有发现的问题
|
||||
|
||||
// 1. 无障碍(基线增量)
|
||||
const pa11yHtmlcs = readJson(path.join(resultsDir, 'a11y/pa11y-htmlcs.json'));
|
||||
|
|
@ -91,6 +93,8 @@ const axeDelta = baseline ? a11yDelta(pa11yAxe, baseline.a11y.axe) : pa11yAxe ||
|
|||
const pluginA11y = htmlcsDelta.length + axeDelta.length;
|
||||
const a11yVerdict = judgeLower(pluginA11y, criteria.accessibility?.violations);
|
||||
if (a11yVerdict === 'pass') passCount++; else if (a11yVerdict === 'fail') failCount++; else warnCount++;
|
||||
dimensions.push({ name: 'accessibility', verdict: a11yVerdict, metrics: { pluginIssues: pluginA11y, threshold: criteria.accessibility?.violations?.pass ?? null } });
|
||||
if (pluginA11y > 0) [...htmlcsDelta, ...axeDelta].forEach(i => issues.push({ dimension: 'accessibility', severity: i.type, detail: `${i.code} @ ${i.selector}` }));
|
||||
let a11yText = `## ${badge(a11yVerdict)} 无障碍 (a11y)\n- 插件新增: ${pluginA11y} 个问题 (阈值: ≤${criteria.accessibility?.violations?.pass ?? '?'})`;
|
||||
a11yText += `\n- 基线 (WordPress 核心): HTML_CodeSniffer ${pa11yHtmlcs?.length ?? 0} / axe-core ${pa11yAxe?.length ?? 0}`;
|
||||
if (pluginA11y > 0) {
|
||||
|
|
@ -115,6 +119,9 @@ if (lhReport?.categories) {
|
|||
lhLines.push(`- ${badge(verdict)} ${v.title}: ${score}${threshold}`);
|
||||
}
|
||||
if (lhWorst === 'pass') passCount++; else if (lhWorst === 'fail') failCount++; else warnCount++;
|
||||
const lhScores = {};
|
||||
for (const [key, v] of Object.entries(lhReport.categories)) lhScores[key] = Math.floor(v.score * 100);
|
||||
dimensions.push({ name: 'lighthouse', verdict: lhWorst, metrics: lhScores });
|
||||
sections.push(`## ${badge(lhWorst)} 性能 (Lighthouse)\n${lhLines.join('\n')}`);
|
||||
} else {
|
||||
sections.push('## 性能 (Lighthouse)\n- 未运行或无结果');
|
||||
|
|
@ -127,6 +134,7 @@ if (fileExists(linkCsv)) {
|
|||
const broken = (content.match(/^[^#].*error/gm) || []).length;
|
||||
const linkVerdict = judgeLower(broken, criteria.links?.broken);
|
||||
if (linkVerdict === 'pass') passCount++; else if (linkVerdict === 'fail') failCount++; else warnCount++;
|
||||
dimensions.push({ name: 'links', verdict: linkVerdict, metrics: { broken } });
|
||||
sections.push(`## ${badge(linkVerdict)} 链接检查\n- 断链: ${broken} 个 (阈值: ≤${criteria.links?.broken?.pass ?? '?'})`);
|
||||
} else {
|
||||
sections.push('## 链接检查\n- 未运行');
|
||||
|
|
@ -136,6 +144,7 @@ if (fileExists(linkCsv)) {
|
|||
const apiRoutes = readJson(path.join(resultsDir, 'api/routes.json'));
|
||||
const routeCount = apiRoutes?.routes ? Object.keys(apiRoutes.routes).length : 0;
|
||||
if (routeCount > 0) passCount++;
|
||||
dimensions.push({ name: 'api', verdict: routeCount > 0 ? 'pass' : 'skip', metrics: { routes: routeCount } });
|
||||
sections.push(`## REST API\n- 路由数: ${routeCount || '未运行'}`);
|
||||
|
||||
// 5. HTML 验证(基线增量)
|
||||
|
|
@ -146,6 +155,7 @@ if (fileExists(htmlValidate)) {
|
|||
if (hv) {
|
||||
const hvVerdict = judgeLower(hv.delta, criteria.html?.errors);
|
||||
if (hvVerdict === 'pass') passCount++; else if (hvVerdict === 'fail') failCount++; else warnCount++;
|
||||
dimensions.push({ name: 'html', verdict: hvVerdict, metrics: { delta: hv.delta, total: hv.total } });
|
||||
let hvText = `## ${badge(hvVerdict)} HTML 验证\n- 插件新增: ${hv.delta} 个问题 (阈值: ≤${criteria.html?.errors?.pass ?? '?'})\n- 基线 (WordPress 核心): ${hv.total} 个`;
|
||||
if (hv.delta > 0) {
|
||||
for (const [rule, count] of Object.entries(hv.byRule)) hvText += `\n - ${rule}: +${count}`;
|
||||
|
|
@ -155,6 +165,7 @@ if (fileExists(htmlValidate)) {
|
|||
const errors = (content.match(/error/gi) || []).length;
|
||||
const hvVerdict2 = judgeLower(errors, criteria.html?.errors);
|
||||
if (hvVerdict2 === 'pass') passCount++; else if (hvVerdict2 === 'fail') failCount++; else warnCount++;
|
||||
dimensions.push({ name: 'html', verdict: hvVerdict2, metrics: { errors } });
|
||||
sections.push(`## ${badge(hvVerdict2)} HTML 验证\n- 错误: ${errors} 个`);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -168,13 +179,23 @@ if (secReport) {
|
|||
const secCriteria = criteria.security || {};
|
||||
const highCount = secReport.xssIssues + secReport.sqliIssues;
|
||||
const medCount = secReport.csrfMissing;
|
||||
const lowCount = secReport.infoLeaks;
|
||||
// 过滤 WordPress 核心已知信息泄露
|
||||
const knownLeaks = secCriteria.knownWpCoreLeaks || [];
|
||||
const allLeaks = secReport.details?.infoLeak || [];
|
||||
const pluginLeaks = allLeaks.filter(l => l.exposed && !knownLeaks.some(k => l.name.includes(k)));
|
||||
const coreLeaks = allLeaks.filter(l => l.exposed && knownLeaks.some(k => l.name.includes(k)));
|
||||
const lowCount = pluginLeaks.length;
|
||||
const highV = judgeLower(highCount, secCriteria.high);
|
||||
const medV = judgeLower(medCount, secCriteria.medium);
|
||||
const lowV = judgeLower(lowCount, secCriteria.low);
|
||||
const secWorst = [highV, medV, lowV].includes('fail') ? 'fail' : [highV, medV, lowV].includes('warn') ? 'warn' : 'pass';
|
||||
if (secWorst === 'pass') passCount++; else if (secWorst === 'fail') failCount++; else warnCount++;
|
||||
sections.push(`## ${badge(secWorst)} 安全扫描\n- ${badge(highV)} XSS 反射: ${secReport.xssIssues} (阈值: ≤${secCriteria.high?.pass ?? '?'})\n- ${badge(medV)} CSRF 缺失: ${secReport.csrfMissing}\n- ${badge(highV)} SQLi 泄露: ${secReport.sqliIssues}\n- ${badge(lowV)} 信息泄露: ${secReport.infoLeaks}`);
|
||||
dimensions.push({ name: 'security', verdict: secWorst, metrics: { high: highCount, medium: medCount, low: lowCount } });
|
||||
if (highCount > 0) issues.push({ dimension: 'security', severity: 'high', detail: `XSS: ${secReport.xssIssues}, SQLi: ${secReport.sqliIssues}` });
|
||||
if (lowCount > 0) issues.push({ dimension: 'security', severity: 'low', detail: `信息泄露(插件): ${lowCount}` });
|
||||
let secText = `## ${badge(secWorst)} 安全扫描\n- ${badge(highV)} XSS 反射: ${secReport.xssIssues} (阈值: ≤${secCriteria.high?.pass ?? '?'})\n- ${badge(medV)} CSRF 缺失: ${secReport.csrfMissing}\n- ${badge(highV)} SQLi 泄露: ${secReport.sqliIssues}\n- ${badge(lowV)} 信息泄露(插件): ${lowCount} (阈值: ≤${secCriteria.low?.pass ?? '?'})`;
|
||||
if (coreLeaks.length > 0) secText += `\n- ⏭️ 信息泄露(WP核心,已忽略): ${coreLeaks.map(l => l.name).join(', ')}`;
|
||||
sections.push(secText);
|
||||
} else {
|
||||
sections.push('## 安全扫描\n- 未运行');
|
||||
}
|
||||
|
|
@ -194,6 +215,7 @@ if (backstopReport?.tests) {
|
|||
const diffPct = total > 0 ? (failed / total * 100) : 0;
|
||||
const vrVerdict = judgeLower(diffPct, criteria.visualRegression?.diffPercent ? { pass: criteria.visualRegression.diffPercent.pass, warn: criteria.visualRegression.diffPercent.warn } : null) || (failed === 0 ? 'pass' : 'fail');
|
||||
if (vrVerdict === 'pass') passCount++; else if (vrVerdict === 'fail') failCount++; else warnCount++;
|
||||
dimensions.push({ name: 'visualRegression', verdict: vrVerdict, metrics: { passed, failed, diffPercent: Math.round(diffPct * 100) / 100 } });
|
||||
sections.push(`## ${badge(vrVerdict)} 视觉回归 (BackstopJS)\n- 通过: ${passed}\n- 失败: ${failed}`);
|
||||
} else {
|
||||
sections.push('## 视觉回归 (BackstopJS)\n- 未运行或无结果');
|
||||
|
|
@ -220,11 +242,32 @@ if (i18nReport) {
|
|||
// i18n 整体判定: 取溢出和覆盖率中较差的
|
||||
const i18nWorst = [overflowV, coverageV].includes('fail') ? 'fail' : [overflowV, coverageV].includes('warn') ? 'warn' : 'pass';
|
||||
if (i18nWorst === 'pass') passCount++; else if (i18nWorst === 'fail') failCount++; else warnCount++;
|
||||
dimensions.push({ name: 'i18n', verdict: i18nWorst, metrics: { overflow: i18nReport.overflow.issues, coverage: i18nReport.coverage.percent ?? null, hasPot: i18nReport.coverage.status !== 'no_pot' } });
|
||||
if (i18nReport.coverage.status === 'no_pot') issues.push({ dimension: 'i18n', severity: 'medium', detail: 'zip 包缺少 .pot 文件' });
|
||||
sections.push(`## ${badge(i18nWorst)} i18n 多语言\n- ${badge(overflowV)} 溢出检测: ${overflowStatus} (阈值: ≤${i18nCriteria.overflow?.pass ?? '?'})\n- ${badge(coverageV)} 翻译覆盖率: ${coverageStatus} (阈值: ≥${i18nCriteria.coverage?.pass ?? '?'}%)\n- 日期格式: ${dateStatus}\n- 截图: zh_CN=${i18nReport.screenshots.zh_CN} en_US=${i18nReport.screenshots.en_US}`);
|
||||
} else {
|
||||
sections.push('## i18n 多语言\n- 未运行');
|
||||
}
|
||||
|
||||
// 10. PHPCS 静态分析
|
||||
const phpcsReport = readJson(path.join(resultsDir, 'phpcs/phpcs-security.json'));
|
||||
if (phpcsReport) {
|
||||
const pCriteria = criteria.phpcs || {};
|
||||
const highFindings = phpcsReport.findings.filter(f => f.severity === 'high').length;
|
||||
const medFindings = phpcsReport.findings.filter(f => f.severity === 'medium').length;
|
||||
const highV = judgeLower(highFindings, pCriteria.high);
|
||||
const medV = judgeLower(medFindings, pCriteria.medium);
|
||||
const phpcsWorst = [highV, medV].includes('fail') ? 'fail' : [highV, medV].includes('warn') ? 'warn' : 'pass';
|
||||
if (phpcsWorst === 'pass') passCount++; else if (phpcsWorst === 'fail') failCount++; else warnCount++;
|
||||
dimensions.push({ name: 'phpcs', verdict: phpcsWorst, metrics: { securityFindings: phpcsReport.securityFindings, high: highFindings, medium: medFindings, totalErrors: phpcsReport.summary.errors } });
|
||||
if (highFindings > 0) issues.push({ dimension: 'phpcs', severity: 'high', detail: `SQL 未 prepare: ${phpcsReport.byRule['WordPress.DB.PreparedSQL.NotPrepared'] || 0}, Nonce 缺失: ${phpcsReport.byRule['WordPress.Security.NonceVerification.Missing'] || 0}` });
|
||||
let phpcsText = `## ${badge(phpcsWorst)} 静态分析 (PHPCS)\n- ${badge(highV)} 高危: ${highFindings} (SQL注入/CSRF)\n- ${badge(medV)} 中危: ${medFindings} (输出未转义)`;
|
||||
for (const [rule, count] of Object.entries(phpcsReport.byRule)) phpcsText += `\n - ${rule}: ${count}`;
|
||||
sections.push(phpcsText);
|
||||
} else {
|
||||
sections.push('## 静态分析 (PHPCS)\n- 未运行');
|
||||
}
|
||||
|
||||
// === 生成报告 ===
|
||||
const total = passCount + warnCount + failCount;
|
||||
const passThreshold = criteria.overall?.passThreshold ?? 7;
|
||||
|
|
@ -247,5 +290,22 @@ ${sections.join('\n\n')}
|
|||
const reportPath = path.join(resultsDir, 'report.md');
|
||||
fs.mkdirSync(resultsDir, { recursive: true });
|
||||
fs.writeFileSync(reportPath, report);
|
||||
|
||||
// === 生成 verdict.json ===
|
||||
const verdictData = {
|
||||
plugin: pluginName,
|
||||
date: `${date}T${time}`,
|
||||
site: process.env.WP_SITE || 'http://localhost:9400',
|
||||
verdict,
|
||||
summary: { pass: passCount, warn: warnCount, fail: failCount, total },
|
||||
passThreshold,
|
||||
dimensions,
|
||||
issues,
|
||||
criteria: criteria._updated || null
|
||||
};
|
||||
const verdictPath = path.join(resultsDir, 'verdict.json');
|
||||
fs.writeFileSync(verdictPath, JSON.stringify(verdictData, null, 2));
|
||||
|
||||
console.log(`[report] ${verdict} — ${passCount}/${total} 通过, ${warnCount} 警告, ${failCount} 失败`);
|
||||
console.log(`[report] 报告: ${reportPath}`);
|
||||
console.log(`[verdict] ${verdictPath}`);
|
||||
|
|
|
|||
69
scripts/phpcs-scan.sh
Executable file
69
scripts/phpcs-scan.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env bash
|
||||
# PHPCS WordPress 安全 + 编码规范扫描
|
||||
# 用法: phpcs-scan.sh <plugin-dir> <output-dir>
|
||||
set -euo pipefail
|
||||
|
||||
PLUGIN_DIR="${1:?用法: $0 <plugin-dir> <output-dir>}"
|
||||
OUTPUT_DIR="${2:?用法: $0 <plugin-dir> <output-dir>}"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo "[phpcs] 扫描: $PLUGIN_DIR"
|
||||
|
||||
# 完整扫描(JSON 格式)
|
||||
phpcs --standard=WordPress-Extra \
|
||||
--extensions=php \
|
||||
--report=json \
|
||||
-s -n \
|
||||
"$PLUGIN_DIR" \
|
||||
> "$OUTPUT_DIR/phpcs-full.json" 2>/dev/null || true
|
||||
|
||||
# 提取安全相关发现
|
||||
python3 -c "
|
||||
import json, sys
|
||||
with open('$OUTPUT_DIR/phpcs-full.json') as f:
|
||||
data = json.load(f)
|
||||
|
||||
security_rules = ['Security', 'PreparedSQL']
|
||||
security = []
|
||||
summary = {'errors': data['totals']['errors'], 'warnings': data['totals']['warnings'], 'files': len(data['files'])}
|
||||
by_rule = {}
|
||||
|
||||
for fpath, info in data['files'].items():
|
||||
short = fpath.split('/')
|
||||
# 取插件目录后的相对路径
|
||||
try:
|
||||
idx = next(i for i, p in enumerate(short) if p in ('includes','modules','templates','assets')) - 1
|
||||
rel = '/'.join(short[idx:])
|
||||
except StopIteration:
|
||||
rel = '/'.join(short[-2:])
|
||||
|
||||
for msg in info['messages']:
|
||||
src = msg.get('source', '')
|
||||
is_security = any(r in src for r in security_rules)
|
||||
if is_security:
|
||||
security.append({
|
||||
'file': rel,
|
||||
'line': msg['line'],
|
||||
'rule': src,
|
||||
'severity': 'high' if 'PreparedSQL' in src or 'NonceVerification' in src else 'medium',
|
||||
'message': msg['message'][:150]
|
||||
})
|
||||
by_rule[src] = by_rule.get(src, 0) + 1
|
||||
|
||||
result = {
|
||||
'tool': 'phpcs + WordPress-Extra',
|
||||
'summary': summary,
|
||||
'securityFindings': len(security),
|
||||
'byRule': by_rule,
|
||||
'findings': security
|
||||
}
|
||||
|
||||
with open('$OUTPUT_DIR/phpcs-security.json', 'w') as f:
|
||||
json.dump(result, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f'[phpcs] {summary[\"files\"]} 文件, {summary[\"errors\"]} 错误, {summary[\"warnings\"]} 警告')
|
||||
print(f'[phpcs] 安全发现: {len(security)} 条')
|
||||
for rule, count in sorted(by_rule.items(), key=lambda x: -x[1]):
|
||||
print(f' {rule}: {count}')
|
||||
"
|
||||
94
scripts/plugin-check.sh
Executable file
94
scripts/plugin-check.sh
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env bash
|
||||
# WordPress Plugin Check (PCP) 自动化
|
||||
# 用法: plugin-check.sh <plugin-zip> [output-dir]
|
||||
# 需要: Playground CLI, plugin-check.zip
|
||||
set -euo pipefail
|
||||
|
||||
PLUGIN_ZIP="${1:?用法: $0 <plugin-zip> [output-dir]}"
|
||||
OUTPUT_DIR="${2:-/tmp/pcp-results}"
|
||||
PCP_ZIP="/tmp/plugin-check.zip"
|
||||
PORT=9401
|
||||
PLUGIN_NAME=$(basename "$PLUGIN_ZIP" .zip)
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# 确保 PCP zip 存在
|
||||
if [ ! -f "$PCP_ZIP" ]; then
|
||||
echo "[pcp] 下载 Plugin Check..."
|
||||
curl -sL "https://downloads.wordpress.org/plugin/plugin-check.latest-stable.zip" -o "$PCP_ZIP"
|
||||
fi
|
||||
|
||||
# 启动 HTTP server 服务 zip 文件
|
||||
SERVE_DIR=$(mktemp -d)
|
||||
cp "$PLUGIN_ZIP" "$SERVE_DIR/"
|
||||
cp "$PCP_ZIP" "$SERVE_DIR/"
|
||||
python3 -m http.server 8889 --directory "$SERVE_DIR" &>/dev/null &
|
||||
HTTP_PID=$!
|
||||
trap "kill $HTTP_PID 2>/dev/null; rm -rf $SERVE_DIR" EXIT
|
||||
sleep 1
|
||||
|
||||
# 生成临时 Blueprint
|
||||
BLUEPRINT=$(mktemp --suffix=.json)
|
||||
cat > "$BLUEPRINT" <<BPEOF
|
||||
{
|
||||
"\$schema": "https://playground.wordpress.net/blueprint-schema.json",
|
||||
"landingPage": "/wp-admin/",
|
||||
"preferredVersions": { "wp": "6.8", "php": "8.4" },
|
||||
"steps": [
|
||||
{ "step": "login", "username": "admin", "password": "password" },
|
||||
{ "step": "installPlugin", "pluginData": { "resource": "url", "url": "http://127.0.0.1:8889/plugin-check.zip" }, "options": { "activate": true } },
|
||||
{ "step": "installPlugin", "pluginData": { "resource": "url", "url": "http://127.0.0.1:8889/$(basename "$PLUGIN_ZIP")" }, "options": { "activate": true } }
|
||||
]
|
||||
}
|
||||
BPEOF
|
||||
|
||||
echo "[pcp] 启动 Playground (port $PORT)..."
|
||||
npx @wp-playground/cli@3.0.52 server --port=$PORT --login --blueprint="$BLUEPRINT" &>/dev/null &
|
||||
PG_PID=$!
|
||||
trap "kill $HTTP_PID $PG_PID 2>/dev/null; rm -rf $SERVE_DIR $BLUEPRINT" EXIT
|
||||
|
||||
# 等待 Playground 就绪
|
||||
echo "[pcp] 等待 Playground..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s "http://localhost:$PORT/" >/dev/null 2>&1; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 通过 Playground 的 PHP 执行 WP-CLI plugin check
|
||||
echo "[pcp] 运行 Plugin Check..."
|
||||
node -e "
|
||||
const http = require('http');
|
||||
const php = \`
|
||||
<?php
|
||||
// Bootstrap WordPress
|
||||
define('ABSPATH', '/wordpress/');
|
||||
require_once ABSPATH . 'wp-load.php';
|
||||
|
||||
// Run plugin check via WP-CLI if available
|
||||
if (class_exists('WP_CLI')) {
|
||||
WP_CLI::run_command(['plugin', 'check', '${PLUGIN_NAME}', '--format=json']);
|
||||
} else {
|
||||
// Fallback: use PCP API directly
|
||||
if (function_exists('wp_plugin_check_get_checks')) {
|
||||
echo json_encode(['status' => 'pcp_loaded', 'note' => 'Direct API not implemented yet']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'pcp_not_found']);
|
||||
}
|
||||
}
|
||||
\`;
|
||||
// For now, just verify PCP is active
|
||||
http.get('http://localhost:${PORT}/wp-admin/admin.php?page=plugin-check', (res) => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => {
|
||||
const active = data.includes('plugin-check') || data.includes('Plugin Check');
|
||||
console.log(JSON.stringify({ pcpActive: active, statusCode: res.statusCode }));
|
||||
});
|
||||
}).on('error', e => console.log(JSON.stringify({ error: e.message })));
|
||||
" > "$OUTPUT_DIR/pcp-status.json"
|
||||
|
||||
echo "[pcp] 结果: $OUTPUT_DIR/pcp-status.json"
|
||||
cat "$OUTPUT_DIR/pcp-status.json"
|
||||
|
||||
# 清理
|
||||
kill $PG_PID 2>/dev/null || true
|
||||
|
|
@ -8,7 +8,12 @@
|
|||
set -euo pipefail
|
||||
|
||||
OUTBOX_DIR="$HOME/docs/ai-context/outbox"
|
||||
# 优先使用 ~/.vm-name(inventory 名),回退到 hostname
|
||||
if [[ -f "$HOME/.vm-name" ]]; then
|
||||
HOSTNAME=$(cat "$HOME/.vm-name" | tr -d '[:space:]')
|
||||
else
|
||||
HOSTNAME=$(hostname -s 2>/dev/null || cat /etc/hostname 2>/dev/null || echo "unknown")
|
||||
fi
|
||||
|
||||
# 颜色
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
|
|
|
|||
|
|
@ -56,14 +56,16 @@ function importReport(reportPath) {
|
|||
const i18n = data.i18n || {};
|
||||
const vals = [
|
||||
p, v,
|
||||
lh.performance ?? '', lh.accessibility ?? '', lh.bestPractices ?? '', lh.seo ?? '',
|
||||
lh.performance ?? null, lh.accessibility ?? null, lh.bestPractices ?? null, lh.seo ?? null,
|
||||
sec.high ?? 0, sec.medium ?? 0, sec.low ?? 0,
|
||||
data.a11yViolations ?? '', i18n.coverage ?? '', i18n.overflow ?? '',
|
||||
data.htmlErrors ?? '', data.brokenLinks ?? '', data.visualDiffPct ?? '',
|
||||
data.pass ?? '', data.warn ?? '', data.fail ?? '',
|
||||
data.a11yViolations ?? null, i18n.coverage ?? null, i18n.overflow ?? null,
|
||||
data.htmlErrors ?? null, data.brokenLinks ?? null, data.visualDiffPct ?? null,
|
||||
data.pass ?? null, data.warn ?? null, data.fail ?? null,
|
||||
reportPath, data.notes || ''
|
||||
];
|
||||
const placeholders = vals.map(v => `'${String(v).replace(/'/g, "''")}'`).join(',');
|
||||
const placeholders = vals.map(v =>
|
||||
v === null ? 'NULL' : `'${String(v).replace(/'/g, "''")}'`
|
||||
).join(',');
|
||||
sql(`INSERT INTO runs (plugin,version,lh_performance,lh_accessibility,lh_best_practices,lh_seo,security_high,security_medium,security_low,a11y_violations,i18n_coverage,i18n_overflow,html_errors,broken_links,visual_diff_pct,overall_pass,overall_warn,overall_fail,report_path,notes) VALUES (${placeholders})`);
|
||||
console.log(`已导入: ${p} ${v}`);
|
||||
}
|
||||
|
|
@ -90,7 +92,13 @@ function compare(plugin) {
|
|||
console.log(`对比: ${latest[2]} (${latest[3]}) vs ${prev[2]} (${prev[3]})\n`);
|
||||
const numCols = [4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19];
|
||||
for (const i of numCols) {
|
||||
const a = parseFloat(latest[i]) || 0, b = parseFloat(prev[i]) || 0;
|
||||
const aRaw = latest[i], bRaw = prev[i];
|
||||
// 跳过 NULL/空值(未测维度不参与对比)
|
||||
if (!aRaw && !bRaw) continue;
|
||||
if (!aRaw) { console.log(` ${cols[i]}: ${bRaw} → (未测)`); continue; }
|
||||
if (!bRaw) { console.log(` ${cols[i]}: (未测) → ${aRaw}`); continue; }
|
||||
const a = parseFloat(aRaw), b = parseFloat(bRaw);
|
||||
if (isNaN(a) || isNaN(b)) continue;
|
||||
const diff = a - b;
|
||||
if (diff !== 0) {
|
||||
const arrow = diff > 0 ? '↑' : '↓';
|
||||
|
|
|
|||
91
tests/api.spec.js
Normal file
91
tests/api.spec.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// WPMind AJAX 端点 + REST API 测试
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const GOTO_OPTS = { waitUntil: 'domcontentloaded' };
|
||||
|
||||
test.describe('AJAX 端点', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 登录获取 cookie
|
||||
await page.goto('/wp-login.php', GOTO_OPTS);
|
||||
await page.fill('#user_login', 'admin');
|
||||
await page.fill('#user_pass', 'password');
|
||||
await page.click('#wp-submit');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1000);
|
||||
// 导航到 admin 页面确保 cookie 生效
|
||||
await page.goto('/wp-admin/', GOTO_OPTS);
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
const ajaxActions = [
|
||||
'wpmind_get_provider_status',
|
||||
'wpmind_get_routing_status',
|
||||
'wpmind_get_usage_stats',
|
||||
'wpmind_get_budget_status',
|
||||
'wpmind_get_cache_stats',
|
||||
];
|
||||
|
||||
for (const action of ajaxActions) {
|
||||
test(`${action} 端点响应正常`, async ({ page }) => {
|
||||
const response = await page.evaluate(async (act) => {
|
||||
try {
|
||||
const res = await fetch('/wp-admin/admin-ajax.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `action=${act}`,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, hasBody: text.length > 0 };
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
}, action);
|
||||
expect(response.status).toBeDefined();
|
||||
expect(response.hasBody).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('REST API', () => {
|
||||
test('mind/v1 命名空间已注册', async ({ page }) => {
|
||||
const response = await page.goto('/wp-json/mind/v1/', GOTO_OPTS);
|
||||
expect([200, 404]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('models 端点可访问', async ({ page }) => {
|
||||
const response = await page.goto('/wp-json/mind/v1/models', GOTO_OPTS);
|
||||
if (response.status() === 200) {
|
||||
const content = await page.content();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('status 端点可访问', async ({ page }) => {
|
||||
const response = await page.goto('/wp-json/mind/v1/status', GOTO_OPTS);
|
||||
if (response.status() === 200) {
|
||||
const content = await page.content();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('未认证请求被拒绝', async ({ page }) => {
|
||||
const response = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('/wp-json/mind/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'test', messages: [{ role: 'user', content: 'hi' }] }),
|
||||
});
|
||||
return { status: res.status };
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
});
|
||||
if (response.status) {
|
||||
expect(response.status).not.toBe(500);
|
||||
expect([401, 403, 404]).toContain(response.status);
|
||||
}
|
||||
});
|
||||
});
|
||||
63
tests/frontend.spec.js
Normal file
63
tests/frontend.spec.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// WPMind GEO 模块前端功能测试
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const GOTO_OPTS = { waitUntil: 'domcontentloaded' };
|
||||
|
||||
test.describe('GEO 模块前端', () => {
|
||||
test('Markdown Feed 可访问', async ({ request }) => {
|
||||
// feed 返回下载流,用 request API 而非 page.goto
|
||||
const response = await request.get('/?feed=markdown');
|
||||
expect(response.status()).not.toBe(500);
|
||||
});
|
||||
|
||||
test('llms.txt 可访问', async ({ page }) => {
|
||||
const response = await page.goto('/llms.txt', GOTO_OPTS);
|
||||
if (response.status() === 200) {
|
||||
const content = await page.content();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect([200, 404]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('AI Sitemap 可访问', async ({ page }) => {
|
||||
const response = await page.goto('/ai-sitemap.xml', GOTO_OPTS);
|
||||
if (response.status() === 200) {
|
||||
const content = await page.content();
|
||||
expect(content).toMatch(/xml|sitemap/i);
|
||||
}
|
||||
expect([200, 404]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('robots.txt 存在', async ({ page }) => {
|
||||
const response = await page.goto('/robots.txt', GOTO_OPTS);
|
||||
expect(response.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('前端页面健康', () => {
|
||||
test('首页无 PHP 错误', async ({ page }) => {
|
||||
await page.goto('/', GOTO_OPTS);
|
||||
const content = await page.content();
|
||||
expect(content).not.toMatch(/Fatal error|Warning:|Parse error|Notice:/);
|
||||
expect(content).not.toMatch(/Call to undefined/);
|
||||
});
|
||||
|
||||
test('首页无 JS 控制台错误', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
await page.goto('/', GOTO_OPTS);
|
||||
await page.waitForTimeout(3000);
|
||||
// 过滤 Playground 环境的已知无害错误
|
||||
const realErrors = errors.filter(e =>
|
||||
!e.includes('favicon') &&
|
||||
!e.includes('net::ERR') &&
|
||||
!e.includes('404') &&
|
||||
!e.includes('CORS') &&
|
||||
!e.includes('Access-Control-Allow-Origin')
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
66
tests/security.spec.js
Normal file
66
tests/security.spec.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// WPMind 安全性测试
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const GOTO_OPTS = { waitUntil: 'domcontentloaded', timeout: 60000 };
|
||||
|
||||
test.describe.serial('安全性', () => {
|
||||
test('未登录访问设置页 — 认证保护', async ({ page }) => {
|
||||
// Playground 自动登录,admin 页面可能直接可访问
|
||||
// 用 request API 检查 HTTP 层面的认证行为,避免 Playwright 导航超时
|
||||
const response = await page.request.get('/wp-admin/admin.php?page=wpmind', {
|
||||
maxRedirects: 0,
|
||||
}).catch(() => null);
|
||||
if (response) {
|
||||
// 302 重定向到 login 或 200 (Playground 自动登录) 都可接受
|
||||
expect([200, 302]).toContain(response.status());
|
||||
}
|
||||
});
|
||||
|
||||
test('AJAX 端点需要认证', async ({ page }) => {
|
||||
await page.goto('/', GOTO_OPTS);
|
||||
const response = await page.evaluate(async () => {
|
||||
const res = await fetch('/wp-admin/admin-ajax.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'action=wpmind_get_provider_status',
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text.substring(0, 200) };
|
||||
});
|
||||
expect(response.body).not.toMatch(/api_key|secret|token/i);
|
||||
});
|
||||
|
||||
test('设置页不泄露 API Key 明文', async ({ page }) => {
|
||||
await page.goto('/wp-login.php', GOTO_OPTS);
|
||||
await page.fill('#user_login', 'admin');
|
||||
await page.fill('#user_pass', 'password');
|
||||
await page.click('#wp-submit');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.goto('/wp-admin/admin.php?page=wpmind', { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForTimeout(2000);
|
||||
const html = await page.content();
|
||||
expect(html).not.toMatch(/sk-[a-zA-Z0-9]{20,}/);
|
||||
});
|
||||
|
||||
test('XSS 防护 — 搜索参数不反射', async ({ page }) => {
|
||||
const payload = '<script>alert(1)</script>';
|
||||
await page.goto(`/?s=${encodeURIComponent(payload)}`, GOTO_OPTS);
|
||||
const html = await page.content();
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('目录遍历防护', async ({ page }) => {
|
||||
const paths = [
|
||||
'/wp-content/plugins/wpmind/.env',
|
||||
'/wp-content/plugins/wpmind/composer.json',
|
||||
'/wp-content/plugins/wpmind/vendor/',
|
||||
];
|
||||
for (const p of paths) {
|
||||
const response = await page.goto(p, GOTO_OPTS);
|
||||
expect([403, 404]).toContain(response.status());
|
||||
}
|
||||
});
|
||||
});
|
||||
88
tests/settings.spec.js
Normal file
88
tests/settings.spec.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// WPMind 设置页面 + 模块管理测试
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const ADMIN_URL = '/wp-admin/admin.php?page=wpmind';
|
||||
const GOTO_OPTS = { waitUntil: 'domcontentloaded', timeout: 60000 };
|
||||
|
||||
async function login(page) {
|
||||
await page.goto('/wp-login.php', GOTO_OPTS);
|
||||
await page.fill('#user_login', 'admin');
|
||||
await page.fill('#user_pass', 'password');
|
||||
await page.click('#wp-submit');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// 串行执行,共享登录上下文,避免 Playground 并发压力
|
||||
test.describe.serial('设置页面基础', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('设置页面可访问', async ({ page }) => {
|
||||
// 第一次访问 admin 页面可能很慢(PHP 冷启动)
|
||||
await page.goto(ADMIN_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator('h1, .wrap h1, .wrap h2').first()).toBeVisible();
|
||||
await expect(page).not.toHaveTitle(/错误|Error/i);
|
||||
});
|
||||
|
||||
test('所有标签页可切换', async ({ page }) => {
|
||||
await page.goto(ADMIN_URL, GOTO_OPTS);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const tabs = ['overview', 'services', 'images', 'routing', 'modules'];
|
||||
for (const tab of tabs) {
|
||||
const tabEl = page.locator(`[href*="#${tab}"], [data-tab="${tab}"], .nav-tab[href*="${tab}"]`).first();
|
||||
if (await tabEl.isVisible()) {
|
||||
await tabEl.click();
|
||||
await page.waitForTimeout(500);
|
||||
expect(await page.locator('body').innerText()).not.toBe('');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('概览标签页显示插件信息', async ({ page }) => {
|
||||
await page.goto(ADMIN_URL, GOTO_OPTS);
|
||||
await page.waitForTimeout(2000);
|
||||
const content = await page.content();
|
||||
expect(content).toMatch(/WPMind|wpmind|0\.11/i);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('模块管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test('模块列表可见', async ({ page }) => {
|
||||
await page.goto(ADMIN_URL, GOTO_OPTS);
|
||||
await page.waitForTimeout(2000);
|
||||
const modulesTab = page.locator('[href*="#modules"], [data-tab="modules"]').first();
|
||||
if (await modulesTab.isVisible()) {
|
||||
await modulesTab.click();
|
||||
await page.waitForTimeout(1000);
|
||||
const content = await page.content();
|
||||
expect(content).toMatch(/api.gateway|analytics|cost.control|exact.cache|geo|media.intelligence|auto.meta/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('模块切换 AJAX 端点可用', async ({ page }) => {
|
||||
await page.goto(ADMIN_URL, GOTO_OPTS);
|
||||
await page.waitForTimeout(2000);
|
||||
const response = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('/wp-admin/admin-ajax.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'action=wpmind_toggle_module&module_id=test&enable=0',
|
||||
});
|
||||
return { status: res.status, ok: res.ok };
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
});
|
||||
expect(response.status).toBeDefined();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue