Compare commits

..

No commits in common. "main" and "master" have entirely different histories.
main ... master

35 changed files with 3139 additions and 143 deletions

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# === 默认忽略一切,只跟踪验收测试基础设施 ===
*

# 允许跟踪的文件
!.gitignore
!Justfile
!backstop.json
!pa11y.json
!acceptance-criteria.json
!playwright.config.js

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

# 排除脚本目录中的非测试文件
scripts/prlcc-watchdog.sh

# 生成数据 / 大文件(不跟踪)
backstop_data/
baselines/
node_modules/
*.log

363
Justfile Normal file
View file

@ -0,0 +1,363 @@
# WordPress 插件自动化验收测试
# 用法: just <任务名> [参数]
# 查看所有任务: just --list

set dotenv-load
set shell := ["bash", "-cu"]

# 变量
site := env("WP_SITE", "http://localhost:9400")
plugin := env("WP_PLUGIN", "")
ssh_target := env("SSH_TARGET", "")
debug_log := env("WP_DEBUG_LOG_PATH", "/var/www/html/wp-content/debug.log")
pg_ver := env("PLAYGROUND_VERSION", "3.0.52")
pg_wp := env("PLAYGROUND_WP", "6.8")
pg_php := env("PLAYGROUND_PHP", "8.4")
chrome := env("CHROME_PATH", home_directory() / ".cache/ms-playwright/chromium-1208/chrome-linux/chrome")
date := `date +%Y-%m-%d`
results := home_directory() / "test-results" / date / plugin
blueprints := home_directory() / "blueprints"

# ─── Playground 环境管理 ───────────────────────────

# 启动干净 WordPress Playground
playground:
npx @wp-playground/cli@{{pg_ver}} server --port=9400 --login

# 启动 Playground + 加载 Blueprint
playground-bp name:
npx @wp-playground/cli@{{pg_ver}} server --port=9400 --login \
--blueprint={{blueprints}}/{{name}}.json

# 启动 Playground + 挂载本地插件目录
playground-dev path:
npx @wp-playground/cli@{{pg_ver}} server --port=9400 --login \
--mount={{path}}:/wordpress/wp-content/plugins/

# 中文基础环境
playground-zh:
npx @wp-playground/cli@{{pg_ver}} server --port=9400 --login \
--blueprint={{blueprints}}/zh-cn-base.json

# 执行 Blueprint不启动 serverCI 用)
run-bp name:
npx @wp-playground/cli@{{pg_ver}} run-blueprint \
--blueprint={{blueprints}}/{{name}}.json

# 打包快照
snapshot name:
mkdir -p {{results}}
npx @wp-playground/cli@{{pg_ver}} build-snapshot \
--blueprint={{blueprints}}/{{name}}.json \
--outfile={{results}}/{{name}}-snapshot.zip

# ─── 完整验收流程 ──────────────────────────────────

# 一键拉取 + 验收(从 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
set -euo pipefail
export WP_PLUGIN="{{name}}"
RESULTS="$HOME/test-results/{{date}}/{{name}}"
echo "=== 验收插件: {{name}} ==="
echo "目标: {{site}}"
echo "结果: $RESULTS"
just setup-dirs "{{name}}"
# CLI 工具扫描
just a11y-scan "{{name}}" || true
just lighthouse-scan "{{name}}" || true
just link-check "{{name}}" || true
just api-scan "{{name}}" || true
just html-validate-page "{{name}}" "{{site}}" || true
if [ -n "{{ssh_target}}" ]; then
just server-errors "{{name}}" || true
fi
# 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 ==="

# ─── 测试任务 ──────────────────────────────────────

# 创建结果目录
setup-dirs name:
mkdir -p ~/test-results/{{date}}/{{name}}/{screenshots,a11y,performance,security,api,i18n,diff}

# 无障碍扫描pa11y + axe 双引擎)
a11y-scan name:
#!/usr/bin/env bash
set -uo pipefail
DIR="$HOME/test-results/{{date}}/{{name}}/a11y"
mkdir -p "$DIR"
echo "[pa11y+axe] 扫描 {{site}} ..."
pa11y "{{site}}" --config ~/pa11y.json --reporter json > "$DIR/pa11y-htmlcs.json" 2>&1 || true
PA11Y_ISSUES=$(jq 'length' "$DIR/pa11y-htmlcs.json" 2>/dev/null || echo "?")
echo "[pa11y/htmlcs] 发现 $PA11Y_ISSUES 个问题"
pa11y "{{site}}" --config ~/pa11y.json --runner axe --reporter json > "$DIR/pa11y-axe.json" 2>&1 || true
AXE_ISSUES=$(jq 'length' "$DIR/pa11y-axe.json" 2>/dev/null || echo "?")
echo "[pa11y/axe] 发现 $AXE_ISSUES 个问题"
echo "[a11y] 结果: $DIR/"

# Lighthouse 性能/SEO 扫描
lighthouse-scan name:
#!/usr/bin/env bash
set -uo pipefail
DIR="$HOME/test-results/{{date}}/{{name}}/performance"
mkdir -p "$DIR"
echo "[lighthouse] 扫描 {{site}} ..."
CHROME_PATH="{{chrome}}" lighthouse "{{site}}" \
--output=json --output=html \
--output-path="$DIR/lighthouse" \
--chrome-flags="--headless --no-sandbox --disable-setuid-sandbox" \
--quiet 2>&1 || true
if [ -f "$DIR/lighthouse.report.json" ]; then
jq -r '.categories | to_entries[] | " \(.value.title): \(.value.score * 100 | floor)"' "$DIR/lighthouse.report.json" 2>/dev/null || true
fi

# 全站死链检测
link-check name:
#!/usr/bin/env bash
set -uo pipefail
DIR="$HOME/test-results/{{date}}/{{name}}"
mkdir -p "$DIR"
echo "[linkchecker] 检测 {{site}} ..."
linkchecker "{{site}}" --output=csv > "$DIR/link-check.csv" 2>&1 || true
BROKEN=$(grep -c "^[^#].*error" "$DIR/link-check.csv" 2>/dev/null || echo "0")
echo "[linkchecker] 发现 $BROKEN 个断链"

# REST API 端点扫描
api-scan name:
#!/usr/bin/env bash
set -uo pipefail
DIR="$HOME/test-results/{{date}}/{{name}}/api"
mkdir -p "$DIR"
echo "[api] 扫描 {{site}}/wp-json/ ..."
curl -sL -c /tmp/wp-just-cookies.txt -b /tmp/wp-just-cookies.txt "{{site}}/wp-json/" > "$DIR/routes.json" 2>&1 || true
ROUTES=$(jq '.routes | length' "$DIR/routes.json" 2>/dev/null || echo "?")
echo "[api] 发现 $ROUTES 个路由"

# HTML 验证(单页)
html-validate-page name url:
#!/usr/bin/env bash
set -uo pipefail
DIR="$HOME/test-results/{{date}}/{{name}}"
mkdir -p "$DIR"
echo "[html-validate] 验证 {{url}} ..."
curl -sL -c /tmp/wp-just-cookies.txt -b /tmp/wp-just-cookies.txt "{{url}}" > /tmp/validate-page.html 2>/dev/null
html-validate /tmp/validate-page.html > "$DIR/html-validate.txt" 2>&1 || true
ERRORS=$(grep -c "error" "$DIR/html-validate.txt" 2>/dev/null || echo "0")
echo "[html-validate] 发现 $ERRORS 个问题"

# 服务端错误检查(需要 SSH 访问)
server-errors name:
#!/usr/bin/env bash
set -uo pipefail
DIR="$HOME/test-results/{{date}}/{{name}}"
mkdir -p "$DIR"
if [ -z "{{ssh_target}}" ]; then
echo "[server] 跳过: 未配置 SSH_TARGETPlayground 模式无需此步)"
exit 0
fi
echo "[server] 拉取 debug.log ..."
ssh "{{ssh_target}}" "cat {{debug_log}}" > "$DIR/debug.log" 2>&1 || true
FATALS=$(grep -c "Fatal" "$DIR/debug.log" 2>/dev/null || echo "0")
WARNINGS=$(grep -c "Warning" "$DIR/debug.log" 2>/dev/null || echo "0")
echo "[server] Fatal: $FATALS, Warning: $WARNINGS"

# ─── 视觉回归 ─────────────────────────────────────

# 建立视觉基线
visual-baseline:
backstop reference --config=backstop.json

# 视觉回归对比
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
DIR="$HOME/test-results/{{date}}/{{name}}/diff"
mkdir -p "$DIR"
reg-cli "{{actual}}" "{{expected}}" "$DIR" --report "$DIR/report.html"
echo "[diff] 报告: $DIR/report.html"

# ─── Playwright 交互流程 ──────────────────────────

# 多分辨率截图4 视口 × 4 页面)
screenshots name:
node scripts/playwright/screenshots.js "{{name}}" "{{site}}"

# 插件安装验收(上传→激活→截图→检查菜单)
plugin-install zip:
node scripts/playwright/plugin-install.js "{{zip}}" "{{site}}"

# 设置页面验收(字段收集→保存→刷新验证)
settings-test path="/wp-admin/options-general.php":
node scripts/playwright/settings-test.js "{{path}}" "{{site}}"

# 基础安全扫描XSS/CSRF/SQLi/信息泄露)
security-scan name="default":
node scripts/playwright/security-scan.js "{{site}}"

# i18n 验收(溢出 + 中英截图 + 覆盖率 + 日期格式)
i18n-test name zip="":
#!/usr/bin/env bash
set -uo pipefail
STARTED_EN=false
RESULTS="$HOME/test-results/{{date}}/{{name}}/i18n"
mkdir -p "$RESULTS"
# Auto-start en_US Playground if not running
if ! ss -tlnp | grep -q ':9401'; then
echo "[i18n] 启动 en_US Playground..."
npx @wp-playground/cli@{{pg_ver}} server --port=9401 --login \
--blueprint={{blueprints}}/en-us-base.json &>/dev/null &
STARTED_EN=true
sleep 8
fi
cleanup() { if [ "$STARTED_EN" = "true" ]; then pkill -f 'port=9401' 2>/dev/null || true; fi; }
trap cleanup EXIT
EN_URL="http://localhost:9401"
if ! ss -tlnp | grep -q ':9401'; then
echo "[i18n] en_US 启动失败,仅测试 zh_CN"
EN_URL=""
fi
ZIP_ARG=""
if [ -n "{{zip}}" ] && [ -f "{{zip}}" ]; then ZIP_ARG="{{zip}}"; fi
node scripts/playwright/i18n-test.js "{{name}}" "{{site}}" "$EN_URL" "$RESULTS" "$ZIP_ARG"

# 启动英文 Playgroundi18n 对比用)
playground-en:
npx @wp-playground/cli@{{pg_ver}} server --port=9401 --login \
--blueprint={{blueprints}}/en-us-base.json

# ─── 数据库管理(远程站点) ─────────────────────────

# 数据库快照
db-snapshot:
#!/usr/bin/env bash
if [ -z "{{ssh_target}}" ]; then echo "需要 SSH_TARGET"; exit 1; fi
ssh "{{ssh_target}}" "wp db export ~/backups/pre-test-$(date +%Y%m%d-%H%M).sql"
echo "[db] 快照已保存"

# 数据库回滚
db-rollback file:
#!/usr/bin/env bash
if [ -z "{{ssh_target}}" ]; then echo "需要 SSH_TARGET"; exit 1; fi
ssh "{{ssh_target}}" "wp db import {{file}}"
echo "[db] 已回滚到 {{file}}"

# ─── 工具 ─────────────────────────────────────────

# 监控 NAS staging 目录,自动跑验收
watch-staging:
scripts/staging-watcher.sh

# 检查一次 staging适合 cron
check-staging:
scripts/staging-watcher.sh --once

# 列出所有测试结果
results:
@ls -la ~/test-results/{{date}}/ 2>/dev/null || echo "今天没有测试结果"

# 清理旧结果(保留最近 7 天)
clean-results:
find ~/test-results/ -maxdepth 1 -type d -mtime +7 -exec rm -rf {} \;
echo "已清理 7 天前的测试结果"

View file

@ -1,41 +0,0 @@
# play.wenpai.net

文派体验场 — WordPress Playground 自托管站点

## 目录结构

```
mu-plugins/
wenpai-accelerate.php # 国内加速 mu-pluginAPI 重定向 + Cravatar
blueprints/
starter.json # 默认蓝图(中文环境 + 加速)
wpmind.json # WPMind 插件体验蓝图
```

## mu-plugins/wenpai-accelerate.php

轻量级国内加速插件,替代 wp-china-yes 在 WASM 环境中的角色:

- `api.wordpress.org` → `api.wenpai.net`
- `downloads.wordpress.org` → `downloads.wenpai.net`
- Gravatar → Cravatar 国内源

作为 mu-plugin 自动加载,无需激活,无设置页面。

## 部署

蓝图通过 `writeFile` step 将 mu-plugin 写入 Playground 虚拟文件系统:

```json
{
"step": "writeFile",
"path": "/wordpress/wp-content/mu-plugins/wenpai-accelerate.php",
"data": "<php source>"
}
```

## 服务器

- 生产: feicode-prod (45.117.8.70)
- 站点根目录: `/www/wwwroot/play.wenpai.net/`
- 详细文档: 见 wenpai VM `docs/services/playground.md`

51
acceptance-criteria.json Normal file
View file

@ -0,0 +1,51 @@
{
"_comment": "验收标准量化基线 — 各维度 pass/warn/fail 阈值",
"_updated": "2026-02-19",

"lighthouse": {
"performance": { "pass": 80, "warn": 60, "description": "性能评分" },
"accessibility": { "pass": 90, "warn": 70, "description": "无障碍评分" },
"bestPractices": { "pass": 90, "warn": 75, "description": "最佳实践评分" },
"seo": { "pass": 85, "warn": 70, "description": "SEO 评分" }
},

"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 核心已知信息泄露,不计入插件安全评分"
},

"accessibility": {
"violations": { "pass": 0, "warn": 3, "description": "axe 违规数" }
},

"html": {
"errors": { "pass": 0, "warn": 5, "description": "HTML 验证错误数" }
},

"links": {
"broken": { "pass": 0, "warn": 2, "description": "断链数" }
},

"i18n": {
"coverage": { "pass": 95, "warn": 80, "description": "翻译覆盖率 (%)" },
"overflow": { "pass": 0, "warn": 3, "description": "文本溢出元素数" },
"potFile": { "pass": true, "description": "zip 中必须包含 .pot 文件" }
},

"visualRegression": {
"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 个通过才算整体通过"
}
}

50
backstop.json Normal file
View file

@ -0,0 +1,50 @@
{
"id": "wp-plugin-acceptance",
"viewports": [
{ "label": "desktop", "width": 1280, "height": 800 },
{ "label": "tablet", "width": 768, "height": 1024 },
{ "label": "mobile", "width": 375, "height": 812 }
],
"scenarios": [
{
"label": "frontend-home",
"url": "http://localhost:9400/",
"delay": 2000,
"misMatchThreshold": 0.1
},
{
"label": "admin-dashboard",
"url": "http://localhost:9400/wp-admin/",
"delay": 2000,
"misMatchThreshold": 0.1
},
{
"label": "admin-plugins",
"url": "http://localhost:9400/wp-admin/plugins.php",
"delay": 1500,
"misMatchThreshold": 0.1
},
{
"label": "admin-settings",
"url": "http://localhost:9400/wp-admin/options-general.php",
"delay": 1500,
"misMatchThreshold": 0.5,
"requireSameDimensions": false
}
],
"paths": {
"bitmaps_reference": "backstop_data/bitmaps_reference",
"bitmaps_test": "backstop_data/bitmaps_test",
"html_report": "backstop_data/html_report",
"ci_report": "backstop_data/ci_report"
},
"engine": "puppeteer",
"engineOptions": {
"executablePath": "/home/parallels/.cache/ms-playwright/chromium-1212/chrome-linux/chrome",
"args": ["--no-sandbox", "--disable-gpu"]
},
"asyncCaptureLimit": 2,
"asyncCompareLimit": 10,
"debug": false,
"debugWindow": false
}

View file

@ -0,0 +1,26 @@
{
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
"landingPage": "/wp-admin/",
"preferredVersions": {
"wp": "6.8",
"php": "8.4"
},
"steps": [
{
"step": "setSiteOptions",
"options": {
"blogname": "Test Site",
"blogdescription": "WordPress Plugin Acceptance Test",
"WPLANG": "",
"timezone_string": "Asia/Shanghai",
"date_format": "Y-m-d",
"time_format": "H:i"
}
},
{
"step": "login",
"username": "admin",
"password": "password"
}
]
}

View file

@ -1,15 +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": "writeFile",
"path": "/wordpress/wp-content/mu-plugins/wenpai-accelerate.php",
"data": "<?php\n/**\n * Plugin Name: WenPai Accelerate (Playground)\n * Description: API redirect + CORS fix for China WASM env\n * Version: 1.1.0\n */\nadd_filter('pre_http_request', function($preempt, $args, $url) {\n $host = parse_url($url, PHP_URL_HOST);\n if (!in_array($host, ['api.wordpress.org', 'downloads.wordpress.org'])) return $preempt;\n $new_url = str_replace(['api.wordpress.org', 'downloads.wordpress.org'], ['api.wenpai.net', 'downloads.wenpai.net'], $url);\n if (isset($args['headers']) && is_array($args['headers'])) {\n unset($args['headers']['wp_blog'], $args['headers']['wp_install']);\n }\n return wp_remote_request($new_url, $args);\n}, 10, 3);\nadd_filter('get_avatar_url', function($url) {\n return str_replace(['www.gravatar.com','0.gravatar.com','1.gravatar.com','2.gravatar.com','secure.gravatar.com','cn.gravatar.com'], 'cn.cravatar.com', $url);\n}, 1);\n"
},
{ "step": "login", "username": "admin", "password": "password" }
]
}

View file

@ -1,16 +1,14 @@
{
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
"landingPage": "/wp-admin/admin.php?page=wpmind",
"preferredVersions": { "php": "8.3", "wp": "latest" },
"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": "writeFile",
"path": "/wordpress/wp-content/mu-plugins/wenpai-accelerate.php",
"data": "<?php\n/**\n * Plugin Name: WenPai Accelerate (Playground)\n * Description: API redirect + CORS fix for China WASM env\n * Version: 1.1.0\n */\nadd_filter('pre_http_request', function($preempt, $args, $url) {\n $host = parse_url($url, PHP_URL_HOST);\n if (!in_array($host, ['api.wordpress.org', 'downloads.wordpress.org'])) return $preempt;\n $new_url = str_replace(['api.wordpress.org', 'downloads.wordpress.org'], ['api.wenpai.net', 'downloads.wenpai.net'], $url);\n if (isset($args['headers']) && is_array($args['headers'])) {\n unset($args['headers']['wp_blog'], $args['headers']['wp_install']);\n }\n return wp_remote_request($new_url, $args);\n}, 10, 3);\nadd_filter('get_avatar_url', function($url) {\n return str_replace(['www.gravatar.com','0.gravatar.com','1.gravatar.com','2.gravatar.com','secure.gravatar.com','cn.gravatar.com'], 'cn.cravatar.com', $url);\n}, 1);\n"
},
{ "step": "installPlugin", "pluginData": { "resource": "url", "url": "https://play.wenpai.net/plugins/wpmind-0.11.3.zip" } },
{ "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

@ -0,0 +1,26 @@
{
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
"landingPage": "/wp-admin/",
"preferredVersions": {
"wp": "6.8",
"php": "8.4"
},
"steps": [
{
"step": "setSiteOptions",
"options": {
"blogname": "文派测试站",
"blogdescription": "WordPress 插件验收测试环境",
"WPLANG": "zh_CN",
"timezone_string": "Asia/Shanghai",
"date_format": "Y-m-d",
"time_format": "H:i"
}
},
{
"step": "login",
"username": "admin",
"password": "password"
}
]
}

View file

@ -1,80 +0,0 @@
<?php
/**
* Plugin Name: WenPai Accelerate (Playground)
* Description: Lightweight API redirect for China — replaces wp-china-yes in WASM env.
* Version: 1.1.0
* Author: WenPai.org
* License: GPL-2.0-or-later
*
* 功能:
* 1. 将 api.wordpress.org / downloads.wordpress.org 重定向到 wenpai.net 镜像
* 2. 将 Gravatar 头像重定向到 Cravatar 国内源
* 3. 剥离 wp_blog/wp_install 自定义 header修复 WASM 环境 CORS preflight 失败)
* 4. 静默 WP_DEBUG 日志噪音Playground 环境非关键错误)
*
* 设计原则:
* - 极简实现,避免 wp-china-yes 在 WASM 环境中的兼容性问题
* - 无设置页面、无数据库写入、无外部依赖
* - 作为 mu-plugin 自动加载,无需激活
*/

// 防止直接访问
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/**
* 重定向 WordPress.org API 请求到 WenPai.net 镜像
*
* @param false|array|\WP_Error $preempt 预处理结果
* @param array $args 请求参数
* @param string $url 请求 URL
* @return false|array|\WP_Error
*/
add_filter(
'pre_http_request',
function ( $preempt, $args, $url ) {
$host = wp_parse_url( $url, PHP_URL_HOST );
if ( ! in_array( $host, array( 'api.wordpress.org', 'downloads.wordpress.org' ), true ) ) {
return $preempt;
}
$new_url = str_replace(
array( 'api.wordpress.org', 'downloads.wordpress.org' ),
array( 'api.wenpai.net', 'downloads.wenpai.net' ),
$url
);
// 剥离 wp_blog/wp_install 自定义 header
// 避免 WASM Service Worker fetch() 触发 CORS preflight 被 api.wenpai.net 拒绝
if ( isset( $args['headers'] ) && is_array( $args['headers'] ) ) {
unset( $args['headers']['wp_blog'], $args['headers']['wp_install'] );
}
return wp_remote_request( $new_url, $args );
},
10,
3
);

/**
* 重定向 Gravatar 头像到 Cravatar 国内源
*
* @param string $url 头像 URL
* @return string
*/
add_filter(
'get_avatar_url',
function ( $url ) {
return str_replace(
array(
'www.gravatar.com',
'0.gravatar.com',
'1.gravatar.com',
'2.gravatar.com',
'secure.gravatar.com',
'cn.gravatar.com',
),
'cn.cravatar.com',
$url
);
},
1
);

9
pa11y.json Normal file
View file

@ -0,0 +1,9 @@
{
"chromeLaunchConfig": {
"executablePath": "/home/parallels/.cache/ms-playwright/chromium-1208/chrome-linux/chrome",
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
},
"standard": "WCAG2AA",
"includeWarnings": true,
"timeout": 30000
}

View 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" }
]
}

View 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" }
]
}

View file

@ -0,0 +1,37 @@
# play.wenpai.net 改进计划

> 维护者elementaryQA、wenpai开发、fedora-devops部署
> 创建2026-02-21

## 已完成

### v1 — 基础上线2026-02-20
- Gutenberg 风格入口页 + WPMind/空白中文环境两个 Blueprint
- COEP 头移除Playground 用 Service Worker 实现跨域隔离,不需要 HTTP 头)
- Google Fonts → admincdn 国内镜像
- "国际线路"链接修复(指向 playground.wordpress.net

### v1.1 — 数据外置 + 去外部字体2026-02-21
- 插件注册表从 index.html 硬编码提取到独立 `plugins.json`
- 移除 Google Fonts 依赖,使用系统字体栈
- 新增产品只需编辑 plugins.json无需改 index.html

## 待做(按优先级排序)

### P0 — 直接促进收入
1. **集市联动** — play 卡片加"去集市购买"链接,集市产品页加"在线试用"按钮,形成转化闭环
2. **更多 Blueprint** — 集市首批上架产品都应有对应 blueprint让用户先试后买

### P1 — 体验优化
3. **加载提示** — Playground 启动需几秒,加 loading 状态提示
4. **移动端提示** — Playground 在手机上体验有限,提示用桌面浏览器

### P2 — 长期演进
5. **产品独立落地页** — `play.wenpai.net/wpmind` 格式,利于 SEO 和分享
6. **访问统计** — 追踪试用数据,指导集市选品
7. **自动部署** — git hook 或 CI 替代手动 scp

## 已知问题(等 wenpai 修复)
- WPMind 插件内 jsdelivr CDN 被墙remixicon CSS→ 需替换为 jsd.admincdn.com
- WPMind chart.js 404 → 需修复资源路径
- WPMind gravatar 被墙 → 需替换为 cravatar.cn

418
play/index.html Normal file
View file

@ -0,0 +1,418 @@
<!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>
<meta name="description" content="在浏览器中即时体验 WordPress 插件与主题,无需安装任何软件">
<meta property="og:title" content="文派体验场">
<meta property="og:description" content="在浏览器中即时体验 WordPress 插件与主题">
<meta property="og:url" content="https://play.wenpai.net/">
<style>
:root {
--wp-blue: #3858e9;
--wp-blue-hover: #1d35b4;
--wp-dark: #1e1e1e;
--wp-gray-900: #1e1e1e;
--wp-gray-700: #757575;
--wp-gray-600: #949494;
--wp-gray-300: #ddd;
--wp-gray-100: #f0f0f0;
--wp-gray-050: #f6f7f7;
--wp-white: #ffffff;
--wp-alert-bg: #fcf9e8;
--wp-alert-border: #f0c33c;
--wp-alert-text: #50401e;
--wp-radius: 2px;
--wp-font: -apple-system, 'PingFang SC',
'Microsoft YaHei', 'Noto Sans SC', sans-serif;
--wp-max-width: 960px;
--wp-transition: 120ms ease;
}
*, *::before, *::after {
margin: 0; padding: 0; box-sizing: border-box;
}
body {
font-family: var(--wp-font);
background: var(--wp-white);
color: var(--wp-dark);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>

<!-- Hero -->
<header class="hero">
<div class="hero-pattern"></div>
<div class="hero-content">
<div class="hero-wp-mark" aria-hidden="true">
<svg viewBox="0 0 122.5 122.5" xmlns="http://www.w3.org/2000/svg">
<path d="M8.7 61.3c0 20.8 12.1 38.7 29.6 47.3L13 39.6a52.3
52.3 0 0 0-4.3 21.7zm88.6-2.7c0-6.5-2.3-11-4.3-14.5-2.7
-4.3-5.2-8-5.2-12.3 0-4.8 3.7-9.3 8.9-9.3h.7a52.4 52.4
0 0 0-79.4 1.4h4c6.5 0 16.6-.8 16.6-.8 3.4-.2 3.8 4.7.4
5.1 0 0-3.4.4-7.1.6l22.5 67 13.5-40.6-9.6-26.4c-3.4-.2
-6.6-.6-6.6-.6-3.4-.2-3-5.3.4-5.1 0 0 10.3.8 16.4.8 6.5
0 16.6-.8 16.6-.8 3.4-.2 3.8 4.7.4 5.1 0 0-3.4.4-7.2
.6l22.4 66.5 6.2-20.6c2.7-8.6 4.7-14.7 4.7-20zm-37.8
7.9L40.4 113a52.6 52.6 0 0 0 32.3 1 4.7 4.7 0 0 1-.4
-.7zm47.8-32.4c.2 1.7.4 3.6.4 5.6 0 5.5-1 11.7-4.2
19.4l-16.8 48.4c16.3-9.5 27.3-27.2 27.3-47.4 0-9.6-2.5
-18.6-6.7-26zM61.3 0a61.3 61.3 0 1 0 0 122.5A61.3 61.3
0 0 0 61.3 0zm0 119.7a58.5 58.5 0 1 1 0-117 58.5 58.5
0 0 1 0 117z" fill="currentColor"/>
</svg>
</div>
<h1 class="hero-title">文派体验场</h1>
<p class="hero-subtitle">在浏览器中即时体验 WordPress 插件与主题</p>
<p class="hero-desc">基于 WordPress Playground所有环境运行在浏览器沙盒中无需服务器</p>
</div>
</header>

<!-- Main -->
<main class="main">
<div class="container">
<div class="notice" role="note">
<svg class="notice-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<p>所有体验环境均为临时沙盒关闭页面后数据不会保留。AI 相关功能需配置 API Key 后使用。</p>
</div>
<div class="grid" id="plugins"></div>
</div>
</main>

<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-inner">
<span>Powered by
<a href="https://wordpress.github.io/wordpress-playground/">WordPress Playground</a>
</span>
<span class="footer-sep" aria-hidden="true"></span>
<span><a href="https://wenpai.net">文派</a></span>
</div>
</div>
</footer>

<style>
.hero {
position: relative;
background: var(--wp-gray-900);
color: var(--wp-white);
padding: 5rem 1.5rem 4.5rem;
text-align: center;
overflow: hidden;
}
.hero-pattern {
position: absolute; inset: 0; opacity: 0.04;
background-image:
linear-gradient(45deg, currentColor 25%, transparent 25%),
linear-gradient(-45deg, currentColor 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, currentColor 75%),
linear-gradient(-45deg, transparent 75%, currentColor 75%);
background-size: 30px 30px;
background-position: 0 0, 0 15px, 15px -15px, -15px 0;
animation: drift 20s linear infinite;
}
@keyframes drift {
to { background-position: 30px 0, 30px 15px, 45px -15px, -15px 0; }
}
.hero-content {
position: relative;
max-width: var(--wp-max-width);
margin: 0 auto;
animation: fadeUp 0.6s ease both;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-wp-mark {
width: 48px; height: 48px;
margin: 0 auto 1.75rem;
color: var(--wp-blue); opacity: 0.85;
}
.hero-wp-mark svg { width: 100%; height: 100%; }
.hero-title {
font-size: 2.25rem; font-weight: 600;
letter-spacing: -0.025em; line-height: 1.2;
margin-bottom: 0.75rem;
}
.hero-subtitle {
font-size: 1.05rem; font-weight: 400;
color: rgba(255,255,255,0.75); line-height: 1.6;
margin-bottom: 0.375rem;
}
.hero-desc {
font-size: 0.8125rem;
color: rgba(255,255,255,0.4); line-height: 1.6;
}

.main {
padding: 3rem 0 4rem;
background: var(--wp-gray-050);
min-height: 50vh;
}
.container {
max-width: var(--wp-max-width);
margin: 0 auto; padding: 0 1.5rem;
}

.notice {
display: flex; align-items: flex-start; gap: 0.625rem;
background: var(--wp-alert-bg);
border-left: 3px solid var(--wp-alert-border);
border-radius: 0 var(--wp-radius) var(--wp-radius) 0;
padding: 0.875rem 1rem; margin-bottom: 2rem;
font-size: 0.8125rem; color: var(--wp-alert-text);
line-height: 1.6;
animation: fadeUp 0.6s 0.15s ease both;
}
.notice-icon {
flex-shrink: 0; width: 18px; height: 18px;
margin-top: 1px; color: var(--wp-alert-border);
}

.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.25rem;
}

.card {
background: var(--wp-white);
border: 1px solid var(--wp-gray-300);
border-radius: var(--wp-radius);
padding: 1.5rem;
transition: border-color var(--wp-transition),
box-shadow var(--wp-transition);
animation: fadeUp 0.6s ease both;
}
.card:hover {
border-color: var(--wp-blue);
box-shadow: 0 0 0 1px var(--wp-blue);
}
.card-header {
display: flex; align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.card-tag {
display: inline-block;
font-size: 0.6875rem; font-weight: 500;
letter-spacing: 0.03em; text-transform: uppercase;
padding: 0.125rem 0.5rem;
border-radius: var(--wp-radius);
background: var(--wp-blue); color: var(--wp-white);
}
.card-tag.core { background: var(--wp-gray-700); }
.card-version {
font-size: 0.75rem; color: var(--wp-gray-600);
font-variant-numeric: tabular-nums;
}
.card-name {
font-size: 1.125rem; font-weight: 600;
letter-spacing: -0.01em; line-height: 1.3;
margin-bottom: 0.5rem;
}
.card-desc {
font-size: 0.8125rem; color: var(--wp-gray-700);
line-height: 1.7; margin-bottom: 1.25rem;
}
.card-actions {
display: flex; flex-wrap: wrap; gap: 0.5rem;
}

.btn {
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.5rem 1rem; border-radius: var(--wp-radius);
font-family: var(--wp-font);
font-size: 0.8125rem; font-weight: 500;
text-decoration: none; line-height: 1;
transition: background var(--wp-transition),
color var(--wp-transition),
box-shadow var(--wp-transition);
cursor: pointer; border: none;
}
.btn svg { width: 14px; height: 14px; flex-shrink: 0; }
.btn-primary {
background: var(--wp-blue); color: var(--wp-white);
}
.btn-primary:hover { background: var(--wp-blue-hover); }
.btn-secondary {
background: transparent; color: var(--wp-blue);
box-shadow: inset 0 0 0 1px var(--wp-gray-300);
}
.btn-secondary:hover {
box-shadow: inset 0 0 0 1px var(--wp-blue);
}
.btn-tertiary {
background: transparent; color: var(--wp-gray-700);
padding: 0.5rem 0.625rem;
}
.btn-tertiary:hover { color: var(--wp-blue); }

.footer {
padding: 2rem 0;
border-top: 1px solid var(--wp-gray-300);
background: var(--wp-white);
}
.footer-inner {
display: flex; align-items: center;
justify-content: center; gap: 0.75rem;
font-size: 0.8125rem; color: var(--wp-gray-600);
}
.footer a {
color: var(--wp-gray-700); text-decoration: none;
transition: color var(--wp-transition);
}
.footer a:hover { color: var(--wp-blue); }
.footer-sep {
width: 3px; height: 3px;
border-radius: 50%; background: var(--wp-gray-300);
}

@media (max-width: 640px) {
.hero { padding: 3.5rem 1.25rem 3rem; }
.hero-title { font-size: 1.75rem; }
.hero-subtitle { font-size: 0.9375rem; }
.grid { grid-template-columns: 1fr; }
.card-actions { flex-direction: column; }
.card-actions .btn { justify-content: center; }
}

.card:nth-child(1) { animation-delay: 0.2s; }
.card:nth-child(2) { animation-delay: 0.3s; }
.card:nth-child(3) { animation-delay: 0.4s; }
.card:nth-child(4) { animation-delay: 0.5s; }
.card:nth-child(5) { animation-delay: 0.6s; }
.card:nth-child(6) { animation-delay: 0.7s; }
</style>

<script>
// ── 插件注册表 — 从 plugins.json 动态加载 ──
var PLAYGROUND_BASE = window.location.origin;

function playUrl(bp) {
var origin = window.location.origin;
return origin + '/playground.html?blueprint-url=' + origin + bp;
}

function playUrlRemote(bp) {
return 'https://playground.wordpress.net/?blueprint-url=' + PLAYGROUND_BASE + bp;
}

// ── DOM 构建(无 innerHTML──
function createSvg(pathMarkup) {
var t = document.createElement('template');
t.innerHTML = pathMarkup.trim();
return t.content.firstChild;
}

function makeIcon(type) {
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');

if (type === 'play') {
var poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
poly.setAttribute('points', '5 3 19 12 5 21 5 3');
svg.appendChild(poly);
} else if (type === 'globe') {
var c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('cx', '12'); c.setAttribute('cy', '12'); c.setAttribute('r', '10');
svg.appendChild(c);
var l = document.createElementNS('http://www.w3.org/2000/svg', 'line');
l.setAttribute('x1', '2'); l.setAttribute('y1', '12');
l.setAttribute('x2', '22'); l.setAttribute('y2', '12');
svg.appendChild(l);
var p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', 'M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z');
svg.appendChild(p);
} else if (type === 'code') {
var p1 = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
p1.setAttribute('points', '16 18 22 12 16 6');
svg.appendChild(p1);
var p2 = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
p2.setAttribute('points', '8 6 2 12 8 18');
svg.appendChild(p2);
}
return svg;
}

function makeBtn(cls, href, iconType, label) {
var a = document.createElement('a');
a.className = 'btn ' + cls;
a.href = href;
a.target = '_blank';
a.rel = 'noopener';
a.appendChild(makeIcon(iconType));
a.appendChild(document.createTextNode(' ' + label));
return a;
}

var grid = document.getElementById('plugins');

function renderPlugins(plugins) {
var frag = document.createDocumentFragment();
plugins.forEach(function(p) {
var card = document.createElement('div');
card.className = 'card';

var header = document.createElement('div');
header.className = 'card-header';
var tag = document.createElement('span');
tag.className = 'card-tag' + (p.tagClass ? ' ' + p.tagClass : '');
tag.textContent = p.tag;
var ver = document.createElement('span');
ver.className = 'card-version';
ver.textContent = p.version;
header.appendChild(tag);
header.appendChild(ver);

var name = document.createElement('h2');
name.className = 'card-name';
name.textContent = p.name;

var desc = document.createElement('p');
desc.className = 'card-desc';
desc.textContent = p.desc;

var actions = document.createElement('div');
actions.className = 'card-actions';
actions.appendChild(makeBtn('btn-primary', playUrl(p.blueprint), 'play', '立即体验'));
actions.appendChild(makeBtn('btn-secondary', playUrlRemote(p.blueprint), 'globe', '国际线路'));
if (p.repo) {
actions.appendChild(makeBtn('btn-tertiary', p.repo, 'code', '源码'));
}

card.appendChild(header);
card.appendChild(name);
card.appendChild(desc);
card.appendChild(actions);
frag.appendChild(card);
});
grid.appendChild(frag);
}

fetch('/plugins.json')
.then(function(r) { return r.json(); })
.then(renderPlugins)
.catch(function(e) {
grid.textContent = '加载插件列表失败,请刷新重试。';
console.error('plugins.json load error:', e);
});
</script>
</body>
</html>

22
play/plugins.json Normal file
View file

@ -0,0 +1,22 @@
[
{
"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": ""
}
]

Binary file not shown.

25
playwright.config.js Normal file
View 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
View 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
scripts/fetch-release.sh Executable file
View file

@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Forgejo release 拉取脚本
# 用法:
# ./scripts/fetch-release.sh <owner/repo> [version]
# ./scripts/fetch-release.sh feibisi/wpmind # 拉最新 release
# ./scripts/fetch-release.sh feibisi/wpmind v0.11.3 # 拉指定版本
#
# 下载到: ~/下载/<repo>-<version>.zip

set -euo pipefail

REPO="${1:?用法: fetch-release.sh <owner/repo> [version]}"
VERSION="${2:-}"
DOWNLOAD_DIR="${HOME}/下载"
API_BASE="https://feicode.com/api/v1"

# 从 bashrc 加载 token
source "${HOME}/.bashrc" 2>/dev/null || true
TOKEN="${FORGEJO_TOKEN:?FORGEJO_TOKEN 未设置}"

REPO_NAME=$(basename "$REPO")

# 重试函数(指数退避)
api_call() {
local url="$1" attempt=0 max=3 delay=2
while [ $attempt -lt $max ]; do
local resp
resp=$(curl -sf -H "Authorization: token ${TOKEN}" "$url" 2>/dev/null) && {
echo "$resp"; return 0
}
attempt=$((attempt + 1))
[ $attempt -lt $max ] && sleep $delay && delay=$((delay * 2))
done
echo "API 调用失败: $url" >&2; return 1
}

if [ -n "$VERSION" ]; then
echo "获取 ${REPO} ${VERSION} ..."
RELEASE_JSON=$(api_call "${API_BASE}/repos/${REPO}/releases/tags/${VERSION}")
else
echo "获取 ${REPO} 最新 release ..."
RELEASE_JSON=$(api_call "${API_BASE}/repos/${REPO}/releases?limit=1")
RELEASE_JSON=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)[0]))")
fi

TAG=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tag_name','unknown'))")
echo "版本: $TAG"

# 查找 zip 附件
ASSET_URL=$(echo "$RELEASE_JSON" | python3 -c "
import sys, json
r = json.load(sys.stdin)
for a in r.get('assets', []):
if a['name'].endswith('.zip'):
print(a['browser_download_url'])
break
else:
print('')
")

if [ -z "$ASSET_URL" ]; then
# 没有 zip 附件,用 tarball
ASSET_URL="${API_BASE}/repos/${REPO}/archive/${TAG}.zip"
echo "无 zip 附件,使用源码归档"
fi

OUTPUT="${DOWNLOAD_DIR}/${REPO_NAME}-${TAG}.zip"
echo "下载: ${ASSET_URL}"
curl -sL -H "Authorization: token ${TOKEN}" -o "$OUTPUT" "$ASSET_URL"

if [ -f "$OUTPUT" ] && [ -s "$OUTPUT" ]; then
SIZE=$(du -h "$OUTPUT" | cut -f1)
echo "完成: ${OUTPUT} (${SIZE})"
# 同时复制到 NAS staging 供其他脚本使用
STAGING_DIR="/mnt/shared-context/staging/elementary"
if [ -d "$STAGING_DIR" ]; then
cp "$OUTPUT" "${STAGING_DIR}/${REPO_NAME}.zip"
echo "已复制到 staging: ${STAGING_DIR}/${REPO_NAME}.zip"
fi
else
echo "下载失败" >&2; exit 1
fi

311
scripts/generate-report.js Normal file
View file

@ -0,0 +1,311 @@
// 验收测试汇总报告生成器
// 用法: node scripts/generate-report.js <plugin-name> <results-dir>
const fs = require('fs');
const path = require('path');

const pluginName = process.argv[2] || 'unknown';
const resultsDir = process.argv[3] || '.';
const date = new Date().toISOString().slice(0, 10);
const time = new Date().toISOString().slice(11, 19);

// === 加载验收基线配置 ===
const criteriaPath = path.join(process.env.HOME, 'acceptance-criteria.json');
const criteria = readJson(criteriaPath) || {};

// 判定函数: 值越高越好 (Lighthouse, 覆盖率)
function judgeHigher(value, threshold) {
if (!threshold || value == null) return 'skip';
if (value >= threshold.pass) return 'pass';
if (value >= threshold.warn) return 'warn';
return 'fail';
}

// 判定函数: 值越低越好 (漏洞数, 错误数, 溢出数)
function judgeLower(value, threshold) {
if (!threshold || value == null) return 'skip';
if (value <= threshold.pass) return 'pass';
if (value <= threshold.warn) return 'warn';
return 'fail';
}

// 判定结果标记
function badge(result) {
return result === 'pass' ? '✅' : result === 'warn' ? '⚠️' : result === 'fail' ? '❌' : '⏭️';
}

function readJson(filepath) {
try { return JSON.parse(fs.readFileSync(filepath, 'utf8')); }
catch { return null; }
}

function fileExists(filepath) {
try { return fs.statSync(filepath).isFile(); }
catch { return false; }
}

function countFiles(dir, ext) {
try {
return fs.readdirSync(dir).filter(f => f.endsWith(ext)).length;
} catch { return 0; }
}

// === 加载裸 WordPress 基线 ===
const baselinePath = path.join(process.env.HOME, 'baselines/bare-wp/baseline.json');
const baseline = readJson(baselinePath);

function a11yDelta(testIssues, baselineIssues) {
if (!testIssues || !baselineIssues) return testIssues || [];
const baseSet = new Set(baselineIssues.map(i => `${i.code}||${i.selector}`));
return testIssues.filter(i => !baseSet.has(`${i.code}||${i.selector}`));
}

function htmlValidateDelta(testText, baselineRules) {
if (!testText || !baselineRules) return { total: 0, delta: 0, byRule: {} };
const testRules = {};
for (const line of testText.split('\n')) {
const m = line.match(/error\s+(.+?)\s{2,}(\S+)$/);
if (m) testRules[m[2]] = (testRules[m[2]] || 0) + 1;
}
let total = 0, delta = 0;
const byRule = {};
for (const [rule, count] of Object.entries(testRules)) {
total += count;
const baseCount = baselineRules[rule] || 0;
const diff = Math.max(0, count - baseCount);
if (diff > 0) { delta += diff; byRule[rule] = diff; }
}
return { total, delta, byRule };
}

// === 收集各模块结果 ===
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'));
const pa11yAxe = readJson(path.join(resultsDir, 'a11y/pa11y-axe.json'));
const htmlcsDelta = baseline ? a11yDelta(pa11yHtmlcs, baseline.a11y.htmlcs) : pa11yHtmlcs || [];
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) {
for (const i of [...htmlcsDelta, ...axeDelta]) a11yText += `\n - [${i.type}] ${i.code} @ ${i.selector}`;
}
sections.push(a11yText);

// 2. Lighthouse
const lhReport = readJson(path.join(resultsDir, 'performance/lighthouse.report.json'));
if (lhReport?.categories) {
const lhCriteria = criteria.lighthouse || {};
const catMap = { performance: 'performance', accessibility: 'accessibility', 'best-practices': 'bestPractices', seo: 'seo' };
let lhLines = [];
let lhWorst = 'pass';
for (const [key, v] of Object.entries(lhReport.categories)) {
const score = Math.floor(v.score * 100);
const cKey = catMap[key] || key;
const verdict = judgeHigher(score, lhCriteria[cKey]);
if (verdict === 'fail') lhWorst = 'fail';
else if (verdict === 'warn' && lhWorst !== 'fail') lhWorst = 'warn';
const threshold = lhCriteria[cKey] ? ` (阈值: ≥${lhCriteria[cKey].pass})` : '';
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- 未运行或无结果');
}

// 3. 链接检查
const linkCsv = path.join(resultsDir, 'link-check.csv');
if (fileExists(linkCsv)) {
const content = fs.readFileSync(linkCsv, 'utf8');
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- 未运行');
}

// 4. API 扫描
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 验证(基线增量)
const htmlValidate = path.join(resultsDir, 'html-validate.txt');
if (fileExists(htmlValidate)) {
const content = fs.readFileSync(htmlValidate, 'utf8');
const hv = baseline ? htmlValidateDelta(content, baseline.htmlValidateRules) : null;
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}`;
}
sections.push(hvText);
} else {
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 {
sections.push('## HTML 验证\n- 未运行');
}

// 6. 安全扫描
const secReport = readJson(path.join(resultsDir, 'security/security-report.json'))
|| readJson(path.join(process.env.HOME, `test-results/${date}/security-scan/security/security-report.json`));
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 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);
} else {
sections.push('## 安全扫描\n- 未运行');
}

// 7. 截图
const screenshotDir = path.join(resultsDir, 'screenshots');
const screenshotCount = countFiles(screenshotDir, '.png');
sections.push(`## 截图\n- 数量: ${screenshotCount} 张\n- 目录: screenshots/`);

// 8. 视觉回归
const backstopReport = readJson(path.join(process.env.HOME, 'backstop_data/bitmaps_test',
fs.readdirSync(path.join(process.env.HOME, 'backstop_data/bitmaps_test')).sort().pop() || '', 'report.json'));
if (backstopReport?.tests) {
const passed = backstopReport.tests.filter(t => t.status === 'pass').length;
const failed = backstopReport.tests.filter(t => t.status === 'fail').length;
const total = passed + failed;
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- 未运行或无结果');
}

// 9. i18n
const i18nReport = readJson(path.join(resultsDir, 'i18n/i18n-report.json'));
if (i18nReport) {
const i18nCriteria = criteria.i18n || {};
const overflowV = judgeLower(i18nReport.overflow.issues, i18nCriteria.overflow);
const overflowStatus = i18nReport.overflow.issues === 0 ? 'OK' : `${i18nReport.overflow.issues} 个溢出`;
let coverageStatus = '未检查';
let coverageV = 'skip';
if (i18nReport.coverage.status === 'ok') {
coverageStatus = `${i18nReport.coverage.percent}% (${i18nReport.coverage.translated}/${i18nReport.coverage.total})`;
coverageV = judgeHigher(i18nReport.coverage.percent, i18nCriteria.coverage);
} else if (i18nReport.coverage.status === 'no_pot') {
coverageStatus = '无 .pot 文件';
coverageV = i18nCriteria.potFile?.pass ? 'fail' : 'warn';
} else {
coverageStatus = i18nReport.coverage.status;
}
const dateStatus = i18nReport.date_format?.found ? 'OK' : '未检测到日期';
// 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;
const verdict = failCount > 0 ? 'FAIL' : passCount >= passThreshold ? 'PASS' : 'WARN';

const report = `# ${pluginName} 验收测试报告

- 日期: ${date} ${time}
- 站点: ${process.env.WP_SITE || 'http://localhost:9400'}
- 结果: **${verdict}** (${passCount} 通过 / ${warnCount} 警告 / ${failCount} 失败)
- 通过门槛: ≥${passThreshold}/${total} 维度通过
- 验收基线: acceptance-criteria.json (${criteria._updated || 'unknown'})

${sections.join('\n\n')}

---
*由 wp-plugin-acceptance-test 自动生成 | 验收基线 ${criteria._updated || 'N/A'}*
`;

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
View 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}')
"

View file

@ -0,0 +1,289 @@
// i18n 国际化验收测试
// 用法: node scripts/playwright/i18n-test.js <plugin-name> <zh-url> <en-url> <output-dir> [zip-path]
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
const { execFileSync } = require('child_process');
const os = require('os');

const pluginName = process.argv[2] || 'default';
const zhUrl = process.argv[3] || 'http://localhost:9400';
const enUrl = process.argv[4] || '';
const outDir = process.argv[5]
|| path.join(process.env.HOME, 'test-results', new Date().toISOString().slice(0, 10), pluginName, 'i18n');
const zipPath = process.argv[6] || '';

const PAGES = [
{ name: 'dashboard', path: '/wp-admin/' },
{ name: 'plugins', path: '/wp-admin/plugins.php' },
{ name: 'settings', path: '/wp-admin/options-general.php' },
{ name: 'frontend', path: '/' },
];

const OVERFLOW_SELECTORS = [
'#adminmenu li a', '.wrap h1', '.wrap h2', 'label', 'button',
'.button', 'th', 'td', '.notice', '.updated',
];

function launchBrowser() {
return chromium.launch({
executablePath: path.join(
process.env.HOME,
'.cache/ms-playwright/chromium-1212/chrome-linux/chrome'
),
args: ['--no-sandbox', '--disable-gpu'],
});
}

// --- Check 1: Overflow Detection ---
async function checkOverflow(context, baseUrl) {
console.log('\n[i18n] === 1. Overflow Detection ===');
const issues = [];
let checked = 0;

for (const pg of PAGES) {
const page = await context.newPage();
await page.goto(`${baseUrl}${pg.path}`, { waitUntil: 'load', timeout: 15000 });
await page.waitForTimeout(2000);

const pageIssues = await page.evaluate((selectors) => {
const results = [];
let count = 0;
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
els.forEach((el, idx) => {
count++;
// Skip screen-reader-only elements (intentionally tiny)
if (el.clientWidth < 5 && el.clientHeight < 5) return;
if (el.scrollWidth > el.clientWidth + 2 || el.scrollHeight > el.clientHeight + 2) {
results.push({
selector: sel,
index: idx,
text: (el.textContent || '').trim().slice(0, 60),
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
});
}
});
}
return { results, count };
}, OVERFLOW_SELECTORS);

for (const issue of pageIssues.results) {
issues.push({ page: pg.name, ...issue });
}
checked += pageIssues.count;
console.log(` [${pg.name}] checked ${pageIssues.count} elements, ${pageIssues.results.length} overflow(s)`);
await page.close();
}

console.log(` Total: ${checked} checked, ${issues.length} overflow issues`);
return { checked, issues: issues.length, details: issues };
}

// --- Check 2: Bilingual Screenshots ---
async function takeScreenshots(context, baseUrl, prefix, outputDir) {
const shots = [];
for (const pg of PAGES) {
const page = await context.newPage();
await page.goto(`${baseUrl}${pg.path}`, { waitUntil: 'load', timeout: 15000 });
await page.waitForTimeout(2000);
const filename = `${prefix}-${pg.name}.png`;
await page.screenshot({ path: path.join(outputDir, filename), fullPage: true });
console.log(` [screenshot] ${filename}`);
shots.push(filename);
await page.close();
}
return shots;
}

async function bilingualScreenshots(browser, zhBaseUrl, enBaseUrl, outputDir) {
console.log('\n[i18n] === 2. Bilingual Screenshots ===');
const result = { zh_CN: 0, en_US: 0 };

// zh_CN screenshots
const zhCtx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const zhInit = await zhCtx.newPage();
await zhInit.goto(zhBaseUrl, { waitUntil: 'load', timeout: 15000 });
await zhInit.waitForTimeout(2000);
await zhInit.close();

const zhShots = await takeScreenshots(zhCtx, zhBaseUrl, 'zh', outputDir);
result.zh_CN = zhShots.length;
await zhCtx.close();

// en_US screenshots
if (!enBaseUrl) {
console.log(' [warning] en_US URL not provided, skipping en screenshots');
return result;
}

try {
const enCtx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const enInit = await enCtx.newPage();
await enInit.goto(enBaseUrl, { waitUntil: 'load', timeout: 10000 });
await enInit.waitForTimeout(2000);
await enInit.close();

const enShots = await takeScreenshots(enCtx, enBaseUrl, 'en', outputDir);
result.en_US = enShots.length;
await enCtx.close();
} catch (err) {
console.log(` [warning] en_US instance unavailable: ${err.message}`);
}

return result;
}

// --- Check 3: Translation Coverage ---
function checkTranslationCoverage(zipFilePath) {
console.log('\n[i18n] === 3. Translation Coverage ===');
if (!zipFilePath || !fs.existsSync(zipFilePath)) {
console.log(' [skipped] no zip provided');
return { status: 'skipped', reason: 'no zip provided' };
}

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-coverage-'));
try {
execFileSync('unzip', ['-q', '-o', zipFilePath, '-d', tmpDir]);

// Find .pot files
const potFiles = execFileSync('find', [tmpDir, '-name', '*.pot'])
.toString().trim().split('\n').filter(Boolean);
if (potFiles.length === 0) {
console.log(' [no_pot] no .pot file found in zip');
return { status: 'no_pot', zip: zipFilePath };
}

// Count msgid in .pot
const potContent = fs.readFileSync(potFiles[0], 'utf8');
const potMsgids = potContent.split('\n')
.filter(line => /^msgid "(?!").+"/.test(line));
const totalStrings = potMsgids.length;

// Find zh_CN .po
const poFiles = execFileSync('find', [tmpDir, '-name', '*.po'])
.toString().trim().split('\n').filter(Boolean);
const zhPo = poFiles.find(f => /zh[_-](CN|Hans)/i.test(f));

if (!zhPo) {
console.log(` [no_zh_po] found ${poFiles.length} .po files but none for zh_CN`);
return { status: 'no_zh_po', total: totalStrings, po_files: poFiles.map(f => path.basename(f)) };
}

// Count translated strings in zh_CN .po
const poContent = fs.readFileSync(zhPo, 'utf8');
const blocks = poContent.split(/\n\n+/);
let translated = 0;
for (const block of blocks) {
const msgidMatch = block.match(/^msgid "(.+)"/m);
const msgstrMatch = block.match(/^msgstr "(.+)"/m);
if (msgidMatch && msgidMatch[1] && msgstrMatch && msgstrMatch[1]) {
translated++;
}
}

const percent = totalStrings > 0 ? Math.round((translated / totalStrings) * 100) : 0;
console.log(` pot: ${totalStrings} strings, zh_CN: ${translated} translated (${percent}%)`);
return { status: 'ok', total: totalStrings, translated, percent, pot: path.basename(potFiles[0]), po: path.basename(zhPo) };
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}

// --- Check 4: Date Format ---
async function checkDateFormat(context, baseUrl) {
console.log('\n[i18n] === 4. Date Format Check ===');
const page = await context.newPage();
await page.goto(`${baseUrl}/wp-admin/`, { waitUntil: 'load', timeout: 15000 });
await page.waitForTimeout(2000);

const result = await page.evaluate(() => {
const selectors = ['#dashboard-widgets .inside', '.wrap td', 'time', '.column-date'];
const isoPattern = /\d{4}-\d{2}-\d{2}/;
const zhPattern = /\d{4}年\d{1,2}月\d{1,2}日/;
const samples = [];

for (const sel of selectors) {
const els = document.querySelectorAll(sel);
els.forEach((el) => {
const text = (el.textContent || '').trim();
const isoMatch = text.match(isoPattern);
const zhMatch = text.match(zhPattern);
if (isoMatch) samples.push({ selector: sel, format: 'iso', sample: isoMatch[0], context: text.slice(0, 80) });
if (zhMatch) samples.push({ selector: sel, format: 'zh', sample: zhMatch[0], context: text.slice(0, 80) });
});
}
return samples;
});

await page.close();
const found = result.length > 0;
console.log(` Found ${result.length} date string(s)`);
result.forEach(s => console.log(` [${s.format}] ${s.sample} in <${s.selector}>`));
return { pass: found, samples: result, found };
}

// --- Main ---
(async () => {
console.log(`[i18n] Plugin: ${pluginName}`);
console.log(`[i18n] zh_CN: ${zhUrl}`);
console.log(`[i18n] en_US: ${enUrl || '(not provided)'}`);
console.log(`[i18n] Output: ${outDir}`);
if (zipPath) console.log(`[i18n] Zip: ${zipPath}`);

fs.mkdirSync(outDir, { recursive: true });
const browser = await launchBrowser();

try {
// Init zh_CN session
const zhCtx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const initPage = await zhCtx.newPage();
await initPage.goto(zhUrl, { waitUntil: 'load', timeout: 15000 });
await initPage.waitForTimeout(2000);
await initPage.close();

// 1. Overflow
const overflow = await checkOverflow(zhCtx, zhUrl);

// 4. Date format
const dateFormat = await checkDateFormat(zhCtx, zhUrl);

await zhCtx.close();

// 2. Screenshots
const screenshots = await bilingualScreenshots(browser, zhUrl, enUrl, outDir);

// 3. Coverage
const coverage = checkTranslationCoverage(zipPath);

// 5. Terminology (placeholder)
const terminology = { status: 'skipped', reason: 'translate-vm API not available' };

// Build report
const report = {
dimension: 'i18n',
overflow,
coverage,
date_format: dateFormat,
terminology,
screenshots,
};

const reportPath = path.join(outDir, 'i18n-report.json');
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));

// Summary
console.log('\n[i18n] === Summary ===');
console.log(` Overflow: ${overflow.checked} checked, ${overflow.issues} issues`);
console.log(` Screenshots: zh=${screenshots.zh_CN}, en=${screenshots.en_US}`);
console.log(` Coverage: ${coverage.status === 'ok' ? coverage.percent + '%' : coverage.status}`);
console.log(` Date format: ${dateFormat.found ? dateFormat.samples.length + ' samples' : 'none found'}`);
console.log(` Terminology: ${terminology.status}`);
console.log(` Report: ${reportPath}`);
} finally {
await browser.close();
}
})();

View file

@ -0,0 +1,95 @@
// 插件安装验收流程
// 用法: node scripts/playwright/plugin-install.js <zip-path> [site-url]
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');

const zipPath = process.argv[2];
const siteUrl = process.argv[3] || 'http://localhost:9400';
const date = new Date().toISOString().slice(0, 10);

if (!zipPath) {
console.error('用法: node plugin-install.js <plugin.zip> [site-url]');
process.exit(1);
}

const pluginName = path.basename(zipPath, '.zip');
const outDir = path.join(process.env.HOME, 'test-results', date, pluginName, 'screenshots');

(async () => {
fs.mkdirSync(outDir, { recursive: true });

const browser = await chromium.launch({
executablePath: path.join(
process.env.HOME,
'.cache/ms-playwright/chromium-1212/chrome-linux/chrome'
),
args: ['--no-sandbox', '--disable-gpu'],
});

const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();

// 1. 触发自动登录
console.log('[install] 登录中...');
await page.goto(siteUrl, { waitUntil: 'load', timeout: 15000 });
await page.waitForTimeout(2000);

// 2. 进入插件上传页
console.log('[install] 进入插件上传页...');
await page.goto(`${siteUrl}/wp-admin/plugin-install.php`, { waitUntil: 'load', timeout: 15000 });
await page.click('a.upload-view-toggle');
await page.waitForSelector('input#pluginzip');

// 3. 上传插件 zip
console.log(`[install] 上传 ${zipPath}...`);
await page.setInputFiles('input#pluginzip', zipPath);
await page.screenshot({ path: path.join(outDir, 'install-upload.png') });
await page.click('#install-plugin-submit');

// 4. 等待安装完成
console.log('[install] 等待安装...');
await page.waitForSelector('.wrap', { timeout: 30000 });
await page.waitForTimeout(2000);
await page.screenshot({ path: path.join(outDir, 'install-result.png') });

// 5. 激活插件
const activateLink = page.locator('a:has-text("Activate Plugin"), a:has-text("启用插件")');
if (await activateLink.count() > 0) {
console.log('[install] 激活插件...');
await activateLink.first().click();
await page.waitForLoadState('load');
await page.waitForTimeout(2000);
await page.screenshot({ path: path.join(outDir, 'install-activated.png') });
console.log('[install] 插件已激活');
} else {
console.log('[install] 未找到激活链接,可能安装失败');
}

// 6. 检查插件列表
await page.goto(`${siteUrl}/wp-admin/plugins.php`, { waitUntil: 'load', timeout: 15000 });
await page.waitForTimeout(1000);
await page.screenshot({ path: path.join(outDir, 'install-plugins-list.png') });

// 7. 检查管理菜单是否有新项
const menuItems = await page.locator('#adminmenu li a').allTextContents();
console.log(`[install] 管理菜单项: ${menuItems.length}`);

// 8. 收集控制台错误
const consoleErrors = [];
page.on('console', msg => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
await page.reload({ waitUntil: 'load' });
await page.waitForTimeout(2000);
if (consoleErrors.length > 0) {
fs.writeFileSync(
path.join(outDir, '..', 'console-errors.log'),
consoleErrors.join('\n')
);
console.log(`[install] 发现 ${consoleErrors.length} 个控制台错误`);
}

await browser.close();
console.log(`[install] 完成,截图保存到 ${outDir}`);
})();

View file

@ -0,0 +1,61 @@
// 多分辨率自动截图
// 用法: node scripts/playwright/screenshots.js [plugin-name] [site-url] [out-dir]
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');

const pluginName = process.argv[2] || 'default';
const siteUrl = process.argv[3] || 'http://localhost:9400';
const outDir = process.argv[4]
|| path.join(process.env.HOME, 'test-results', new Date().toISOString().slice(0, 10), pluginName, 'screenshots');

const viewports = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 800 },
{ name: 'wide', width: 1920, height: 1080 },
];

const pages = [
{ name: 'frontend', path: '/' },
{ name: 'admin-dashboard', path: '/wp-admin/' },
{ name: 'admin-plugins', path: '/wp-admin/plugins.php' },
{ name: 'admin-settings', path: '/wp-admin/options-general.php' },
];

(async () => {
fs.mkdirSync(outDir, { recursive: true });

const browser = await chromium.launch({
executablePath: path.join(
process.env.HOME,
'.cache/ms-playwright/chromium-1212/chrome-linux/chrome'
),
args: ['--no-sandbox', '--disable-gpu'],
});

const context = await browser.newContext();
// 先访问首页触发 Playground 自动登录
const initPage = await context.newPage();
await initPage.goto(siteUrl, { waitUntil: 'load', timeout: 15000 });
await initPage.waitForTimeout(2000);
await initPage.close();

let count = 0;
for (const vp of viewports) {
for (const pg of pages) {
const page = await context.newPage();
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(`${siteUrl}${pg.path}`, { waitUntil: 'load', timeout: 15000 });
await page.waitForTimeout(2000);
const filename = `${pg.name}-${vp.name}.png`;
await page.screenshot({ path: path.join(outDir, filename), fullPage: true });
console.log(` [screenshot] ${filename} (${vp.width}x${vp.height})`);
await page.close();
count++;
}
}

await browser.close();
console.log(`[screenshots] ${count} 张截图保存到 ${outDir}`);
})();

View file

@ -0,0 +1,172 @@
// 基础安全扫描XSS/CSRF/信息泄露检测)
// 用法: node scripts/playwright/security-scan.js [site-url] [out-dir]
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');

const siteUrl = process.argv[2] || 'http://localhost:9400';
const outDir = process.argv[3]
|| path.join(process.env.HOME, 'test-results', new Date().toISOString().slice(0, 10), 'security-scan', 'security');

const XSS_PAYLOADS = [
'<script>alert(1)</script>',
'"><img src=x onerror=alert(1)>',
"'-alert(1)-'",
'<svg/onload=alert(1)>',
];

const SQLI_PAYLOADS = ["' OR '1'='1", "1; DROP TABLE wp_posts--", "' UNION SELECT 1,2,3--"];

(async () => {
fs.mkdirSync(outDir, { recursive: true });
const results = { xss: [], csrf: [], sqli: [], infoLeak: [] };

const browser = await chromium.launch({
executablePath: path.join(
process.env.HOME,
'.cache/ms-playwright/chromium-1212/chrome-linux/chrome'
),
args: ['--no-sandbox', '--disable-gpu'],
});

const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();

// 登录
await page.goto(siteUrl, { waitUntil: 'load', timeout: 15000 });
await page.waitForTimeout(2000);

// === 1. XSS 反射检测(上下文感知) ===
console.log('[security] XSS 反射检测...');
for (const payload of XSS_PAYLOADS) {
const testUrl = `${siteUrl}/?s=${encodeURIComponent(payload)}`;
let alertFired = false;
page.on('dialog', async dialog => { alertFired = true; await dialog.dismiss(); });
await page.goto(testUrl, { waitUntil: 'load', timeout: 10000 });
await page.waitForTimeout(500);

// 检查 1: JS 是否实际执行dialog 触发)
if (alertFired) {
results.xss.push({ url: testUrl, payload, reflected: true, severity: 'critical', context: 'js-executed' });
console.log(` [!!] XSS 执行确认: ${payload.slice(0, 30)}...`);
page.removeAllListeners('dialog');
continue;
}
page.removeAllListeners('dialog');

// 检查 2: payload 是否注入了可执行 DOM 元素(未转义的标签/属性)
const domInjected = await page.evaluate((p) => {
// 检查是否有注入的 script/img/svg 标签
if (p.includes('<script')) {
const scripts = document.querySelectorAll('script');
for (const s of scripts) if (s.textContent.includes('alert(1)')) return 'script-injected';
}
if (p.includes('onerror=') || p.includes('onload=')) {
const all = document.querySelectorAll('[onerror], [onload]');
for (const el of all) {
const h = el.getAttribute('onerror') || el.getAttribute('onload') || '';
if (h.includes('alert')) return 'event-handler-injected';
}
}
// 检查 payload 是否出现在属性值中(非文本内容)
if (p.includes("'") || p.includes('"')) {
const inputs = document.querySelectorAll('input, textarea');
for (const el of inputs) {
if (el.value && el.value.includes(p)) return 'in-input-value-escaped';
}
}
return null;
}, payload);

if (domInjected && domInjected !== 'in-input-value-escaped') {
results.xss.push({ url: testUrl, payload, reflected: true, severity: 'high', context: domInjected });
console.log(` [!] XSS DOM 注入: ${payload.slice(0, 30)}... (${domInjected})`);
} else {
// payload 仅出现在文本内容或已转义的属性中 — 安全
const html = await page.content();
if (html.includes(payload)) {
console.log(` [~] XSS 反射但已转义: ${payload.slice(0, 30)}... (text-content-only)`);
}
}
}
console.log(` XSS 检测完成: ${results.xss.length} 个确认问题`);

// === 2. CSRF 检查 ===
console.log('[security] CSRF nonce 检查...');
const adminForms = [
'/wp-admin/options-general.php',
'/wp-admin/profile.php',
'/wp-admin/post-new.php',
];
for (const formPath of adminForms) {
await page.goto(`${siteUrl}${formPath}`, { waitUntil: 'load', timeout: 10000 });
const hasNonce = await page.evaluate(() => {
const nonceFields = document.querySelectorAll(
'input[name="_wpnonce"], input[name="_wp_http_referer"]'
);
return nonceFields.length > 0;
});
results.csrf.push({ page: formPath, hasNonce });
console.log(` ${hasNonce ? '[ok]' : '[!]'} ${formPath}: nonce ${hasNonce ? '存在' : '缺失'}`);
}

// === 3. SQL 注入基础检测 ===
console.log('[security] SQL 注入检测...');
for (const payload of SQLI_PAYLOADS) {
const testUrl = `${siteUrl}/?s=${encodeURIComponent(payload)}`;
const response = await page.goto(testUrl, { waitUntil: 'load', timeout: 10000 });
const html = await page.content();
const hasDbError =
/SQL syntax|mysql_|mysqli_|pg_query|sqlite_|ORA-\d|database error/i.test(html);
if (hasDbError) {
results.sqli.push({ url: testUrl, payload, dbErrorExposed: true });
console.log(` [!] SQL 错误泄露: ${payload}`);
}
}
console.log(` SQLi 检测完成: ${results.sqli.length} 个潜在问题`);

// === 4. 信息泄露检查 ===
console.log('[security] 信息泄露检查...');
const leakChecks = [
{ name: 'REST API 用户枚举', url: '/wp-json/wp/v2/users', check: 'email' },
{ name: 'readme.html', url: '/readme.html', check: 'WordPress' },
{ name: 'wp-config 备份', url: '/wp-config.php.bak', check: 'DB_' },
{ name: 'debug.log', url: '/wp-content/debug.log', check: 'PHP' },
{ name: 'xmlrpc', url: '/xmlrpc.php', check: 'XML-RPC server accepts POST' },
];
for (const check of leakChecks) {
try {
const resp = await page.goto(`${siteUrl}${check.url}`, { waitUntil: 'load', timeout: 10000 });
const status = resp?.status() || 0;
const html = await page.content();
const exposed = status === 200 && html.includes(check.check);
results.infoLeak.push({ name: check.name, url: check.url, status, exposed });
console.log(` ${exposed ? '[!]' : '[ok]'} ${check.name}: ${status} ${exposed ? '暴露' : '安全'}`);
} catch (e) {
results.infoLeak.push({ name: check.name, url: check.url, status: 0, exposed: false, error: e.message });
console.log(` [skip] ${check.name}: ${e.message.split('\n')[0]}`);
}
}

// === 保存报告 ===
const summary = {
site: siteUrl,
date: new Date().toISOString(),
xssIssues: results.xss.length,
csrfMissing: results.csrf.filter(c => !c.hasNonce).length,
sqliIssues: results.sqli.length,
infoLeaks: results.infoLeak.filter(l => l.exposed).length,
details: results,
};

fs.writeFileSync(path.join(outDir, 'security-report.json'), JSON.stringify(summary, null, 2));

console.log('\n[security] === 扫描摘要 ===');
console.log(` XSS 反射: ${summary.xssIssues} 个问题`);
console.log(` CSRF 缺失: ${summary.csrfMissing} 个问题`);
console.log(` SQLi 泄露: ${summary.sqliIssues} 个问题`);
console.log(` 信息泄露: ${summary.infoLeaks} 个问题`);
console.log(` 报告: ${outDir}/security-report.json`);

await browser.close();
})();

View file

@ -0,0 +1,108 @@
// 设置页面验收流程
// 用法: node scripts/playwright/settings-test.js <settings-url> [site-url]
// 示例: node scripts/playwright/settings-test.js /wp-admin/options-general.php
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');

const settingsPath = process.argv[2] || '/wp-admin/options-general.php';
const siteUrl = process.argv[3] || 'http://localhost:9400';
const date = new Date().toISOString().slice(0, 10);
const pageName = settingsPath.replace(/[\/\.]/g, '-').replace(/^-/, '');
const outDir = path.join(process.env.HOME, 'test-results', date, 'settings-test', 'screenshots');

(async () => {
fs.mkdirSync(outDir, { recursive: true });

const browser = await chromium.launch({
executablePath: path.join(
process.env.HOME,
'.cache/ms-playwright/chromium-1212/chrome-linux/chrome'
),
args: ['--no-sandbox', '--disable-gpu'],
});

const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();

// 1. 登录
console.log('[settings] 登录中...');
await page.goto(siteUrl, { waitUntil: 'load', timeout: 15000 });
await page.waitForTimeout(2000);

// 2. 访问设置页
const fullUrl = `${siteUrl}${settingsPath}`;
console.log(`[settings] 访问 ${fullUrl}`);
await page.goto(fullUrl, { waitUntil: 'load', timeout: 15000 });
await page.waitForTimeout(1000);
await page.screenshot({ path: path.join(outDir, `${pageName}-before.png`), fullPage: true });

// 3. 收集所有表单字段
const fields = await page.evaluate(() => {
const inputs = document.querySelectorAll('input, select, textarea');
return Array.from(inputs).map(el => ({
tag: el.tagName.toLowerCase(),
type: el.type || '',
name: el.name || '',
id: el.id || '',
value: el.value || '',
label: el.labels?.[0]?.textContent?.trim() || '',
})).filter(f => f.name && f.type !== 'hidden' && f.type !== 'submit');
});

console.log(`[settings] 发现 ${fields.length} 个表单字段:`);
fields.forEach(f => console.log(` - ${f.name} (${f.tag}/${f.type}) = "${f.value}" [${f.label}]`));

// 4. 保存表单(不修改值,验证保存流程)
const submitBtn = page.locator('input[type="submit"], button[type="submit"]').first();
if (await submitBtn.count() > 0) {
console.log('[settings] 提交表单...');
await submitBtn.click();
await page.waitForLoadState('load');
await page.waitForTimeout(1000);
await page.screenshot({ path: path.join(outDir, `${pageName}-after.png`), fullPage: true });

// 5. 检查是否有成功提示
const notice = await page.locator('.notice-success, .updated, #message').first();
if (await notice.count() > 0) {
const text = await notice.textContent();
console.log(`[settings] 保存成功: ${text.trim()}`);
} else {
console.log('[settings] 未检测到成功提示');
}
} else {
console.log('[settings] 未找到提交按钮');
}

// 6. 刷新验证值是否持久化
console.log('[settings] 刷新验证...');
await page.reload({ waitUntil: 'load' });
await page.waitForTimeout(1000);
const fieldsAfter = await page.evaluate(() => {
const inputs = document.querySelectorAll('input, select, textarea');
return Array.from(inputs)
.filter(el => el.name && el.type !== 'hidden' && el.type !== 'submit')
.map(el => ({ name: el.name, value: el.value }));
});

// 对比前后值
let changed = 0;
for (const before of fields) {
const after = fieldsAfter.find(f => f.name === before.name);
if (after && after.value !== before.value) {
console.log(` [changed] ${before.name}: "${before.value}" → "${after.value}"`);
changed++;
}
}
if (changed === 0) console.log('[settings] 所有字段值保持一致');

// 7. 保存字段报告
const report = { url: fullUrl, fields, fieldsAfter, changed };
fs.writeFileSync(
path.join(outDir, '..', `${pageName}-fields.json`),
JSON.stringify(report, null, 2)
);

await browser.close();
console.log(`[settings] 完成,结果保存到 ${outDir}`);
})();

94
scripts/plugin-check.sh Executable file
View 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

79
scripts/staging-watcher.sh Executable file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
# staging watcher — 监控 NAS staging 目录,检测到新 zip 自动跑验收
# 用法: ./scripts/staging-watcher.sh [--once]
# --once: 只检查一次(适合 cron不加则持续轮询

set -uo pipefail

STAGING="/mnt/shared-context/staging/elementary"
RESULTS_OUT="/mnt/shared-context/staging/fedora-devops/test-results"
LOCAL_RESULTS="$HOME/test-results"
PROCESSED="$STAGING/.processed"
INTERVAL=30

mkdir -p "$STAGING" "$RESULTS_OUT" "$PROCESSED"

check_and_run() {
for zip in "$STAGING"/*.zip; do
[ -f "$zip" ] || continue
name=$(basename "$zip" .zip)
marker="$PROCESSED/$name.done"

# 跳过已处理的
if [ -f "$marker" ] && [ "$marker" -nt "$zip" ]; then
continue
fi

echo "[watcher] 发现新插件: $name"
date_str=$(date +%Y-%m-%d)
result_dir="$LOCAL_RESULTS/$date_str/$name"

# 确保 Playground 在跑
if ! ss -tlnp | grep -q ':9400'; then
echo "[watcher] 启动 Playground..."
npx @wp-playground/cli@3.0.52 server --port=9400 --login \
--blueprint="$HOME/blueprints/zh-cn-base.json" &
sleep 10
fi

# 检查是否有配套 Blueprint
bp="$STAGING/${name}.blueprint.json"
if [ -f "$bp" ]; then
echo "[watcher] 使用自定义 Blueprint: $bp"
# 重启 Playground 用自定义 Blueprint
pkill -f "@wp-playground/cli" 2>/dev/null || true
sleep 2
npx @wp-playground/cli@3.0.52 server --port=9400 --login \
--blueprint="$bp" &
sleep 10
fi

# 安装插件
echo "[watcher] 安装插件..."
node "$HOME/scripts/playwright/plugin-install.js" "$zip" "http://localhost:9400" || true

# 跑完整验收
echo "[watcher] 开始验收..."
just test-plugin "$name" || true

# 复制结果到 NAS
if [ -d "$result_dir" ]; then
cp -r "$result_dir" "$RESULTS_OUT/"
echo "[watcher] 结果已复制到 $RESULTS_OUT/$name/"
fi

# 标记已处理
date > "$marker"
echo "[watcher] $name 验收完成"
done
}

if [ "${1:-}" = "--once" ]; then
check_and_run
else
echo "[watcher] 监控 $STAGING (每 ${INTERVAL}s 轮询)"
while true; do
check_and_run
sleep "$INTERVAL"
done
fi

132
scripts/submit-request.sh Executable file
View file

@ -0,0 +1,132 @@
#!/bin/bash
# VM 端权限请求提交助手 — 生成 .perm.json 到 outbox
# 用法: submit-request.sh --category <cat> --action <act> [选项]
# 示例:
# submit-request.sh --category status_query --action query_vm_status --target-vm fedora --reason "查看状态"
# submit-request.sh --category package_install --action install_package --packages "ripgrep fd-find" --reason "需要搜索工具"
# submit-request.sh --category service_restart --action restart_service --service keyd --reason "键盘映射失效"
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

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

usage() {
cat <<'EOF'
用法: submit-request.sh --category <类别> --action <动作> [选项]

必需参数:
--category 请求类别 (status_query|package_install|service_restart|config_sync|
knowledge_contribution|message_send|run_playbook_safe|cron_change)
--action 具体动作 (query_vm_status|install_package|restart_service|sync_config|
run_playbook|list_vms)
--reason 请求原因

可选参数:
--target-vm 目标 VM 名称 (默认: fedora)
--packages 要安装的包列表 (空格分隔,引号包裹)
--service 要操作的服务名
--playbook 要运行的 playbook 名
--config-path 要同步的配置路径
--extra 额外 JSON 参数 (如 '{"key":"value"}')
EOF
exit 1
}

# 参数解析
CATEGORY="" ACTION="" REASON="" TARGET_VM="fedora"
PACKAGES="" SERVICE="" PLAYBOOK="" CONFIG_PATH="" EXTRA="{}"

while [[ $# -gt 0 ]]; do
case $1 in
--category) CATEGORY="$2"; shift 2 ;;
--action) ACTION="$2"; shift 2 ;;
--reason) REASON="$2"; shift 2 ;;
--target-vm) TARGET_VM="$2"; shift 2 ;;
--packages) PACKAGES="$2"; shift 2 ;;
--service) SERVICE="$2"; shift 2 ;;
--playbook) PLAYBOOK="$2"; shift 2 ;;
--config-path) CONFIG_PATH="$2"; shift 2 ;;
--extra) EXTRA="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo -e "${RED}未知参数: $1${NC}"; usage ;;
esac
done

[[ -z "$CATEGORY" ]] && { echo -e "${RED}缺少 --category${NC}"; usage; }
[[ -z "$ACTION" ]] && { echo -e "${RED}缺少 --action${NC}"; usage; }
[[ -z "$REASON" ]] && { echo -e "${RED}缺少 --reason${NC}"; usage; }

# 确保 outbox 目录存在
mkdir -p "$OUTBOX_DIR"

# 生成请求 ID 和时间戳
REQUEST_ID=$(date +%s)-$$
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date '+%Y-%m-%dT%H:%M:%SZ')
FILENAME="$(date '+%Y-%m-%d-%H%M%S')-perm-${REQUEST_ID}.perm.json"
EXPIRES_AT=$(date -u -d "+60 minutes" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || \
date -u -v+60M '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || \
echo "")

# 分类提示
case "$CATEGORY" in
status_query|knowledge_contribution|message_send|run_playbook_safe)
TIER_HINT="L0" ;;
package_install|service_restart|config_sync|cron_change)
TIER_HINT="L1" ;;
*)
TIER_HINT="unknown" ;;
esac

# 构建 params 对象
PARAMS=$(jq -n \
--arg packages "$PACKAGES" \
--arg service "$SERVICE" \
--arg playbook "$PLAYBOOK" \
--arg config_path "$CONFIG_PATH" \
--argjson extra "$EXTRA" \
'{} +
(if $packages != "" then {packages: ($packages | split(" "))} else {} end) +
(if $service != "" then {service: $service} else {} end) +
(if $playbook != "" then {playbook: $playbook} else {} end) +
(if $config_path != "" then {config_path: $config_path} else {} end) +
$extra')

# 生成请求 JSON
jq -n \
--arg request_id "$REQUEST_ID" \
--arg source_vm "$HOSTNAME" \
--arg target_vm "$TARGET_VM" \
--arg category "$CATEGORY" \
--arg action "$ACTION" \
--arg reason "$REASON" \
--arg tier_hint "$TIER_HINT" \
--arg timestamp "$TIMESTAMP" \
--arg expires_at "$EXPIRES_AT" \
--argjson params "$PARAMS" \
'{
request_id: $request_id,
source_vm: $source_vm,
target_vm: $target_vm,
category: $category,
action: $action,
reason: $reason,
tier_hint: $tier_hint,
params: $params,
timestamp: $timestamp,
expires_at: $expires_at
}' > "$OUTBOX_DIR/$FILENAME"

echo -e "${GREEN}请求已提交:${NC} $OUTBOX_DIR/$FILENAME"
echo -e " 类别: ${YELLOW}${CATEGORY}${NC} (${TIER_HINT})"
echo -e " 动作: ${ACTION}"
echo -e " 目标: ${TARGET_VM}"
echo -e " 原因: ${REASON}"
echo -e "${GREEN}等待 fedora 处理cron */5 或手动运行 process-permission-requests.sh${NC}"

119
scripts/trend-tracker.js Normal file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env node
// 验收结果趋势追踪 — SQLite 存储 + 查询
// 用法:
// node scripts/trend-tracker.js init 初始化数据库
// node scripts/trend-tracker.js import <report.json> 导入验收报告
// node scripts/trend-tracker.js query <plugin> [limit] 查询趋势
// node scripts/trend-tracker.js compare <plugin> 对比最近两次

const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const DB_PATH = path.join(process.env.HOME, 'data', 'acceptance-trends.db');
const DB_DIR = path.dirname(DB_PATH);

function sql(query, opts = []) {
return execFileSync('sqlite3', [...opts, DB_PATH, query], { encoding: 'utf-8' }).trim();
}

function init() {
if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true });
sql(`CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin TEXT NOT NULL,
version TEXT,
run_date TEXT DEFAULT (datetime('now','localtime')),
lh_performance INTEGER,
lh_accessibility INTEGER,
lh_best_practices INTEGER,
lh_seo INTEGER,
security_high INTEGER DEFAULT 0,
security_medium INTEGER DEFAULT 0,
security_low INTEGER DEFAULT 0,
a11y_violations INTEGER,
i18n_coverage REAL,
i18n_overflow INTEGER,
html_errors INTEGER,
broken_links INTEGER,
visual_diff_pct REAL,
overall_pass INTEGER,
overall_warn INTEGER,
overall_fail INTEGER,
report_path TEXT,
notes TEXT
)`);
console.log('数据库已初始化:', DB_PATH);
}

function importReport(reportPath) {
const raw = fs.readFileSync(reportPath, 'utf-8');
const data = JSON.parse(raw);
const p = data.plugin || path.basename(reportPath, '.json');
const v = data.version || 'unknown';
const lh = data.lighthouse || {};
const sec = data.security || {};
const i18n = data.i18n || {};
const vals = [
p, v,
lh.performance ?? null, lh.accessibility ?? null, lh.bestPractices ?? null, lh.seo ?? null,
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,
reportPath, data.notes || ''
];
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}`);
}

function query(plugin, limit = 10) {
const rows = sql(`SELECT version, run_date, lh_performance, lh_accessibility, lh_best_practices, lh_seo, security_high, i18n_coverage, overall_pass, overall_warn, overall_fail FROM runs WHERE plugin='${plugin.replace(/'/g, "''")}' ORDER BY run_date DESC LIMIT ${parseInt(limit)}`);
if (!rows) { console.log('无记录'); return; }
console.log('版本 | 日期 | Perf | A11y | BP | SEO | 高危 | i18n | 通过/警告/失败');
console.log('-'.repeat(85));
rows.split('\n').forEach(row => {
const [ver, date, perf, a11y, bp, seo, sec, i18n, pass, warn, fail] = row.split('|');
console.log(`${(ver||'?').padEnd(8)} | ${date} | ${(perf||'-').padStart(4)} | ${(a11y||'-').padStart(4)} | ${(bp||'-').padStart(4)} | ${(seo||'-').padStart(4)} | ${(sec||'0').padStart(4)} | ${(i18n||'-').padStart(5)} | ${pass||0}/${warn||0}/${fail||0}`);
});
}

function compare(plugin) {
const rows = sql(`SELECT * FROM runs WHERE plugin='${plugin.replace(/'/g, "''")}' ORDER BY run_date DESC LIMIT 2`);
if (!rows) { console.log('不足两次记录,无法对比'); return; }
const lines = rows.split('\n').filter(Boolean);
if (lines.length < 2) { console.log('仅一次记录,无法对比'); return; }
const cols = 'id|plugin|version|run_date|lh_perf|lh_a11y|lh_bp|lh_seo|sec_h|sec_m|sec_l|a11y|i18n_cov|i18n_of|html_err|links|vis_diff|pass|warn|fail|path|notes'.split('|');
const latest = lines[0].split('|');
const prev = lines[1].split('|');
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 diff = a - b;
if (diff !== 0) {
const arrow = diff > 0 ? '↑' : '↓';
console.log(` ${cols[i]}: ${b} → ${a} (${arrow}${Math.abs(diff).toFixed(1)})`);
}
}
}

// CLI
const [,, cmd, ...args] = process.argv;
switch (cmd) {
case 'init': init(); break;
case 'import': importReport(args[0]); break;
case 'query': query(args[0], args[1]); break;
case 'compare': compare(args[0]); break;
default:
console.log('用法: trend-tracker.js <init|import|query|compare> [args]');
}

91
tests/api.spec.js Normal file
View 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
View 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
View 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
View 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();
});
});