Compare commits

..

No commits in common. "a2e193dbddefe5817d21dd7e656ad1bb78529218" and "6efd64221ec63ba30a81161a4226ff927470e56f" have entirely different histories.

19 changed files with 13 additions and 956 deletions

5
.gitignore vendored
View file

@ -7,13 +7,8 @@
!backstop.json
!pa11y.json
!acceptance-criteria.json
!playwright.config.js

# 允许跟踪的目录
!tests/
!tests/**
!play/
!play/**
!blueprints/
!blueprints/**
!scripts/

View file

@ -53,57 +53,6 @@ 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
@ -126,24 +75,10 @@ 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 ==="
@ -245,31 +180,6 @@ 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

View file

@ -12,9 +12,7 @@
"security": {
"high": { "pass": 0, "warn": 0, "description": "高危漏洞数 (0=pass, >0=fail)" },
"medium": { "pass": 0, "warn": 2, "description": "中危漏洞数" },
"low": { "pass": 3, "warn": 5, "description": "低危漏洞数" },
"knownWpCoreLeaks": ["readme.html", "xmlrpc", "debug.log"],
"_leakNote": "WordPress 核心已知信息泄露,不计入插件安全评分"
"low": { "pass": 3, "warn": 5, "description": "低危漏洞数" }
},

"accessibility": {
@ -39,13 +37,8 @@
"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": 8,
"description": "10 维度中至少 N 个通过才算整体通过"
"passThreshold": 7,
"description": "9 维度中至少 N 个通过才算整体通过"
}
}

View file

@ -1,15 +0,0 @@
{
"$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" }
]
}

View file

@ -1,10 +0,0 @@
{
"$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" }
]
}

View file

@ -1,12 +0,0 @@
{
"$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" }
]
}

View file

@ -1,98 +0,0 @@
<!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>

Binary file not shown.

View file

@ -1,25 +0,0 @@
// @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' }],
],
});

View file

@ -1,137 +0,0 @@
#!/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

View file

@ -82,8 +82,6 @@ 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'));
@ -93,8 +91,6 @@ 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) {
@ -119,9 +115,6 @@ 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- 未运行或无结果');
@ -134,7 +127,6 @@ 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- 未运行');
@ -144,7 +136,6 @@ 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 验证(基线增量)
@ -155,7 +146,6 @@ 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}`;
@ -165,7 +155,6 @@ 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 {
@ -179,23 +168,13 @@ if (secReport) {
const secCriteria = criteria.security || {};
const highCount = secReport.xssIssues + secReport.sqliIssues;
const medCount = secReport.csrfMissing;
// 过滤 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 lowCount = secReport.infoLeaks;
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++;
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);
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}`);
} else {
sections.push('## 安全扫描\n- 未运行');
}
@ -215,7 +194,6 @@ 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- 未运行或无结果');
@ -242,32 +220,11 @@ 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;
@ -290,22 +247,5 @@ ${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}`);

View file

@ -1,69 +0,0 @@
#!/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}')
"

View file

@ -1,94 +0,0 @@
#!/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

View file

@ -8,12 +8,7 @@
set -euo pipefail

OUTBOX_DIR="$HOME/docs/ai-context/outbox"
# 优先使用 ~/.vm-nameinventory 名),回退到 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
HOSTNAME=$(hostname -s 2>/dev/null || cat /etc/hostname 2>/dev/null || echo "unknown")

# 颜色
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'

View file

@ -56,16 +56,14 @@ function importReport(reportPath) {
const i18n = data.i18n || {};
const vals = [
p, v,
lh.performance ?? null, lh.accessibility ?? null, lh.bestPractices ?? null, lh.seo ?? null,
lh.performance ?? '', lh.accessibility ?? '', lh.bestPractices ?? '', lh.seo ?? '',
sec.high ?? 0, sec.medium ?? 0, sec.low ?? 0,
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,
data.a11yViolations ?? '', i18n.coverage ?? '', i18n.overflow ?? '',
data.htmlErrors ?? '', data.brokenLinks ?? '', data.visualDiffPct ?? '',
data.pass ?? '', data.warn ?? '', data.fail ?? '',
reportPath, data.notes || ''
];
const placeholders = vals.map(v =>
v === null ? 'NULL' : `'${String(v).replace(/'/g, "''")}'`
).join(',');
const placeholders = vals.map(v => `'${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}`);
}
@ -92,13 +90,7 @@ 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 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 a = parseFloat(latest[i]) || 0, b = parseFloat(prev[i]) || 0;
const diff = a - b;
if (diff !== 0) {
const arrow = diff > 0 ? '↑' : '↓';

View file

@ -1,91 +0,0 @@
// 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);
}
});
});

View file

@ -1,63 +0,0 @@
// 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);
});
});

View file

@ -1,66 +0,0 @@
// 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());
}
});
});

View file

@ -1,88 +0,0 @@
// 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();
});
});