Compare commits
No commits in common. "main" and "master" have entirely different histories.
35 changed files with 3139 additions and 143 deletions
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
363
Justfile
Normal 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(不启动 server,CI 用)
|
||||||
|
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_TARGET(Playground 模式无需此步)"
|
||||||
|
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"
|
||||||
|
|
||||||
|
# 启动英文 Playground(i18n 对比用)
|
||||||
|
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 天前的测试结果"
|
||||||
41
README.md
41
README.md
|
|
@ -1,41 +0,0 @@
|
||||||
# play.wenpai.net
|
|
||||||
|
|
||||||
文派体验场 — WordPress Playground 自托管站点
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
mu-plugins/
|
|
||||||
wenpai-accelerate.php # 国内加速 mu-plugin(API 重定向 + 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
51
acceptance-criteria.json
Normal 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
50
backstop.json
Normal 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
|
||||||
|
}
|
||||||
26
blueprints/en-us-base.json
Normal file
26
blueprints/en-us-base.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
|
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
|
||||||
"landingPage": "/wp-admin/admin.php?page=wpmind",
|
"landingPage": "/wp-admin/admin.php?page=wpmind",
|
||||||
"preferredVersions": { "php": "8.3", "wp": "latest" },
|
"preferredVersions": {
|
||||||
|
"php": "8.3",
|
||||||
|
"wp": "latest"
|
||||||
|
},
|
||||||
"steps": [
|
"steps": [
|
||||||
{ "step": "setSiteLanguage", "language": "zh_CN" },
|
{ "step": "setSiteLanguage", "language": "zh_CN" },
|
||||||
{ "step": "setSiteOptions", "options": { "blogname": "WPMind 体验站", "timezone_string": "Asia/Shanghai", "date_format": "Y-m-d", "time_format": "H:i" } },
|
{ "step": "setSiteOptions", "options": { "blogname": "WPMind 体验站", "timezone_string": "Asia/Shanghai", "date_format": "Y-m-d", "time_format": "H:i" } },
|
||||||
{
|
{ "step": "installPlugin", "pluginData": { "resource": "url", "url": "https://feicode.com/WenPai-org/wpmind/releases/download/v0.11.3/wpmind-0.11.3.zip" } },
|
||||||
"step": "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": "activatePlugin", "pluginPath": "wpmind/wpmind.php" },
|
{ "step": "activatePlugin", "pluginPath": "wpmind/wpmind.php" },
|
||||||
{ "step": "login", "username": "admin", "password": "password" }
|
{ "step": "login", "username": "admin", "password": "password" }
|
||||||
]
|
]
|
||||||
|
|
|
||||||
26
blueprints/zh-cn-base.json
Normal file
26
blueprints/zh-cn-base.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
9
pa11y.json
Normal 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
|
||||||
|
}
|
||||||
10
play/blueprints/starter.json
Normal file
10
play/blueprints/starter.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
|
||||||
|
"landingPage": "/",
|
||||||
|
"preferredVersions": { "php": "8.3", "wp": "latest" },
|
||||||
|
"steps": [
|
||||||
|
{ "step": "setSiteLanguage", "language": "zh_CN" },
|
||||||
|
{ "step": "setSiteOptions", "options": { "blogname": "文派体验站", "timezone_string": "Asia/Shanghai", "date_format": "Y-m-d", "time_format": "H:i" } },
|
||||||
|
{ "step": "login", "username": "admin", "password": "password" }
|
||||||
|
]
|
||||||
|
}
|
||||||
12
play/blueprints/wpmind.json
Normal file
12
play/blueprints/wpmind.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
|
||||||
|
"landingPage": "/wp-admin/admin.php?page=wpmind",
|
||||||
|
"preferredVersions": { "php": "8.3", "wp": "latest" },
|
||||||
|
"steps": [
|
||||||
|
{ "step": "setSiteLanguage", "language": "zh_CN" },
|
||||||
|
{ "step": "setSiteOptions", "options": { "blogname": "WPMind 体验站", "timezone_string": "Asia/Shanghai", "date_format": "Y-m-d", "time_format": "H:i" } },
|
||||||
|
{ "step": "installPlugin", "pluginData": { "resource": "url", "url": "https://play.wenpai.net/plugins/wpmind-0.11.3.zip" } },
|
||||||
|
{ "step": "activatePlugin", "pluginPath": "wpmind/wpmind.php" },
|
||||||
|
{ "step": "login", "username": "admin", "password": "password" }
|
||||||
|
]
|
||||||
|
}
|
||||||
37
play/docs/improvement-plan.md
Normal file
37
play/docs/improvement-plan.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# play.wenpai.net 改进计划
|
||||||
|
|
||||||
|
> 维护者:elementary(QA)、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
418
play/index.html
Normal 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
22
play/plugins.json
Normal 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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
play/plugins/wpmind-0.11.3.zip
Normal file
BIN
play/plugins/wpmind-0.11.3.zip
Normal file
Binary file not shown.
25
playwright.config.js
Normal file
25
playwright.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// @ts-check
|
||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
timeout: 60000,
|
||||||
|
retries: 1,
|
||||||
|
workers: 2,
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.WP_SITE || 'http://127.0.0.1:9400',
|
||||||
|
headless: true,
|
||||||
|
launchOptions: {
|
||||||
|
executablePath: process.env.CHROMIUM_PATH || `${process.env.HOME}/.cache/ms-playwright/chromium-1212/chrome-linux/chrome`,
|
||||||
|
args: ['--no-sandbox', '--disable-gpu'],
|
||||||
|
},
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
outputDir: process.env.TEST_OUTPUT || './test-results-pw',
|
||||||
|
reporter: [
|
||||||
|
['list'],
|
||||||
|
['json', { outputFile: `${process.env.TEST_OUTPUT || './test-results-pw'}/results.json` }],
|
||||||
|
['html', { outputFolder: `${process.env.TEST_OUTPUT ? process.env.TEST_OUTPUT + '-html' : './test-results-pw-html'}`, open: 'never' }],
|
||||||
|
],
|
||||||
|
});
|
||||||
137
scripts/backstop-baseline.sh
Executable file
137
scripts/backstop-baseline.sh
Executable file
|
|
@ -0,0 +1,137 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# BackstopJS 基线版本管理
|
||||||
|
# 用法:
|
||||||
|
# backstop-baseline.sh save <plugin> <version> — 保存当前参考图为指定版本基线
|
||||||
|
# backstop-baseline.sh use <plugin> <version> — 切换到指定版本基线
|
||||||
|
# backstop-baseline.sh latest <plugin> — 切换到最新版本基线
|
||||||
|
# backstop-baseline.sh list [plugin] — 列出可用基线
|
||||||
|
# backstop-baseline.sh auto <plugin> <version> — 自动选择: 有上一版本用上一版本,没有则用最新
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASELINES_DIR="$HOME/backstop_data/baselines"
|
||||||
|
REF_DIR="$HOME/backstop_data/bitmaps_reference"
|
||||||
|
|
||||||
|
mkdir -p "$BASELINES_DIR"
|
||||||
|
|
||||||
|
cmd="${1:-help}"
|
||||||
|
plugin="${2:-}"
|
||||||
|
version="${3:-}"
|
||||||
|
|
||||||
|
die() { echo "[baseline] ERROR: $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
# 版本排序(semver 友好)
|
||||||
|
sorted_versions() {
|
||||||
|
local p="$1"
|
||||||
|
ls -1 "$BASELINES_DIR" 2>/dev/null \
|
||||||
|
| grep "^${p}-" \
|
||||||
|
| sed "s/^${p}-//" \
|
||||||
|
| sort -V
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$cmd" in
|
||||||
|
save)
|
||||||
|
[ -z "$plugin" ] && die "用法: $0 save <plugin> <version>"
|
||||||
|
[ -z "$version" ] && die "用法: $0 save <plugin> <version>"
|
||||||
|
|
||||||
|
# 确认有参考图可保存
|
||||||
|
src="$REF_DIR"
|
||||||
|
[ -L "$src" ] && src="$(readlink -f "$src")"
|
||||||
|
count=$(find "$src" -maxdepth 1 -name "*.png" 2>/dev/null | wc -l)
|
||||||
|
[ "$count" -eq 0 ] && die "bitmaps_reference 中没有 PNG 文件"
|
||||||
|
|
||||||
|
dest="$BASELINES_DIR/${plugin}-${version}"
|
||||||
|
mkdir -p "$dest"
|
||||||
|
cp "$src"/*.png "$dest/"
|
||||||
|
echo "[baseline] 已保存 ${plugin} v${version} 基线 (${count} 张图片) → $dest"
|
||||||
|
;;
|
||||||
|
|
||||||
|
use)
|
||||||
|
[ -z "$plugin" ] && die "用法: $0 use <plugin> <version>"
|
||||||
|
[ -z "$version" ] && die "用法: $0 use <plugin> <version>"
|
||||||
|
|
||||||
|
target="$BASELINES_DIR/${plugin}-${version}"
|
||||||
|
[ ! -d "$target" ] && die "基线不存在: $target"
|
||||||
|
|
||||||
|
# 如果当前 bitmaps_reference 是真实目录且有内容,先备份
|
||||||
|
if [ -d "$REF_DIR" ] && [ ! -L "$REF_DIR" ]; then
|
||||||
|
count=$(find "$REF_DIR" -maxdepth 1 -name "*.png" 2>/dev/null | wc -l)
|
||||||
|
if [ "$count" -gt 0 ]; then
|
||||||
|
backup="$REF_DIR.backup.$(date +%Y%m%d%H%M%S)"
|
||||||
|
mv "$REF_DIR" "$backup"
|
||||||
|
echo "[baseline] 已备份原参考图 → $backup"
|
||||||
|
else
|
||||||
|
rm -rf "$REF_DIR"
|
||||||
|
fi
|
||||||
|
elif [ -L "$REF_DIR" ]; then
|
||||||
|
rm "$REF_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -sf "$target" "$REF_DIR"
|
||||||
|
echo "[baseline] 已切换到 ${plugin} v${version} 基线"
|
||||||
|
;;
|
||||||
|
|
||||||
|
latest)
|
||||||
|
[ -z "$plugin" ] && die "用法: $0 latest <plugin>"
|
||||||
|
latest_ver=$(sorted_versions "$plugin" | tail -1)
|
||||||
|
[ -z "$latest_ver" ] && die "没有找到 ${plugin} 的基线"
|
||||||
|
exec "$0" use "$plugin" "$latest_ver"
|
||||||
|
;;
|
||||||
|
|
||||||
|
auto)
|
||||||
|
[ -z "$plugin" ] && die "用法: $0 auto <plugin> <new-version>"
|
||||||
|
[ -z "$version" ] && die "用法: $0 auto <plugin> <new-version>"
|
||||||
|
|
||||||
|
# 找到比当前版本小的最新版本
|
||||||
|
prev_ver=$(sorted_versions "$plugin" | grep -v "^${version}$" | tail -1 || true)
|
||||||
|
|
||||||
|
if [ -n "$prev_ver" ]; then
|
||||||
|
echo "[baseline] 找到上一版本基线: ${plugin} v${prev_ver}"
|
||||||
|
exec "$0" use "$plugin" "$prev_ver"
|
||||||
|
else
|
||||||
|
# 没有历史基线,尝试用最新的
|
||||||
|
any_ver=$(sorted_versions "$plugin" | tail -1 || true)
|
||||||
|
if [ -n "$any_ver" ]; then
|
||||||
|
echo "[baseline] 无上一版本,使用最新基线: ${plugin} v${any_ver}"
|
||||||
|
exec "$0" use "$plugin" "$any_ver"
|
||||||
|
else
|
||||||
|
echo "[baseline] 无可用基线,将以当前参考图运行(首次验收)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
list)
|
||||||
|
if [ -n "$plugin" ]; then
|
||||||
|
echo "[baseline] ${plugin} 可用基线:"
|
||||||
|
sorted_versions "$plugin" | while read -r v; do
|
||||||
|
count=$(find "$BASELINES_DIR/${plugin}-${v}" -name "*.png" | wc -l)
|
||||||
|
echo " v${v} (${count} 张)"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "[baseline] 所有基线:"
|
||||||
|
ls -1 "$BASELINES_DIR" 2>/dev/null | while read -r d; do
|
||||||
|
count=$(find "$BASELINES_DIR/$d" -name "*.png" | wc -l)
|
||||||
|
echo " $d (${count} 张)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 显示当前激活的基线
|
||||||
|
if [ -L "$REF_DIR" ]; then
|
||||||
|
echo "[baseline] 当前激活: $(readlink "$REF_DIR" | xargs basename)"
|
||||||
|
else
|
||||||
|
echo "[baseline] 当前激活: 本地目录 (未关联版本)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "BackstopJS 基线版本管理"
|
||||||
|
echo ""
|
||||||
|
echo "用法:"
|
||||||
|
echo " $0 save <plugin> <version> 保存当前参考图为版本基线"
|
||||||
|
echo " $0 use <plugin> <version> 切换到指定版本基线"
|
||||||
|
echo " $0 latest <plugin> 切换到最新版本基线"
|
||||||
|
echo " $0 auto <plugin> <version> 自动选择上一版本基线"
|
||||||
|
echo " $0 list [plugin] 列出可用基线"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
82
scripts/fetch-release.sh
Executable file
82
scripts/fetch-release.sh
Executable 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
311
scripts/generate-report.js
Normal 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
69
scripts/phpcs-scan.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# PHPCS WordPress 安全 + 编码规范扫描
|
||||||
|
# 用法: phpcs-scan.sh <plugin-dir> <output-dir>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PLUGIN_DIR="${1:?用法: $0 <plugin-dir> <output-dir>}"
|
||||||
|
OUTPUT_DIR="${2:?用法: $0 <plugin-dir> <output-dir>}"
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
echo "[phpcs] 扫描: $PLUGIN_DIR"
|
||||||
|
|
||||||
|
# 完整扫描(JSON 格式)
|
||||||
|
phpcs --standard=WordPress-Extra \
|
||||||
|
--extensions=php \
|
||||||
|
--report=json \
|
||||||
|
-s -n \
|
||||||
|
"$PLUGIN_DIR" \
|
||||||
|
> "$OUTPUT_DIR/phpcs-full.json" 2>/dev/null || true
|
||||||
|
|
||||||
|
# 提取安全相关发现
|
||||||
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
with open('$OUTPUT_DIR/phpcs-full.json') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
security_rules = ['Security', 'PreparedSQL']
|
||||||
|
security = []
|
||||||
|
summary = {'errors': data['totals']['errors'], 'warnings': data['totals']['warnings'], 'files': len(data['files'])}
|
||||||
|
by_rule = {}
|
||||||
|
|
||||||
|
for fpath, info in data['files'].items():
|
||||||
|
short = fpath.split('/')
|
||||||
|
# 取插件目录后的相对路径
|
||||||
|
try:
|
||||||
|
idx = next(i for i, p in enumerate(short) if p in ('includes','modules','templates','assets')) - 1
|
||||||
|
rel = '/'.join(short[idx:])
|
||||||
|
except StopIteration:
|
||||||
|
rel = '/'.join(short[-2:])
|
||||||
|
|
||||||
|
for msg in info['messages']:
|
||||||
|
src = msg.get('source', '')
|
||||||
|
is_security = any(r in src for r in security_rules)
|
||||||
|
if is_security:
|
||||||
|
security.append({
|
||||||
|
'file': rel,
|
||||||
|
'line': msg['line'],
|
||||||
|
'rule': src,
|
||||||
|
'severity': 'high' if 'PreparedSQL' in src or 'NonceVerification' in src else 'medium',
|
||||||
|
'message': msg['message'][:150]
|
||||||
|
})
|
||||||
|
by_rule[src] = by_rule.get(src, 0) + 1
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'tool': 'phpcs + WordPress-Extra',
|
||||||
|
'summary': summary,
|
||||||
|
'securityFindings': len(security),
|
||||||
|
'byRule': by_rule,
|
||||||
|
'findings': security
|
||||||
|
}
|
||||||
|
|
||||||
|
with open('$OUTPUT_DIR/phpcs-security.json', 'w') as f:
|
||||||
|
json.dump(result, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
print(f'[phpcs] {summary[\"files\"]} 文件, {summary[\"errors\"]} 错误, {summary[\"warnings\"]} 警告')
|
||||||
|
print(f'[phpcs] 安全发现: {len(security)} 条')
|
||||||
|
for rule, count in sorted(by_rule.items(), key=lambda x: -x[1]):
|
||||||
|
print(f' {rule}: {count}')
|
||||||
|
"
|
||||||
289
scripts/playwright/i18n-test.js
Normal file
289
scripts/playwright/i18n-test.js
Normal 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
95
scripts/playwright/plugin-install.js
Normal file
95
scripts/playwright/plugin-install.js
Normal 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}`);
|
||||||
|
})();
|
||||||
61
scripts/playwright/screenshots.js
Normal file
61
scripts/playwright/screenshots.js
Normal 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}`);
|
||||||
|
})();
|
||||||
172
scripts/playwright/security-scan.js
Normal file
172
scripts/playwright/security-scan.js
Normal 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();
|
||||||
|
})();
|
||||||
108
scripts/playwright/settings-test.js
Normal file
108
scripts/playwright/settings-test.js
Normal 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
94
scripts/plugin-check.sh
Executable file
|
|
@ -0,0 +1,94 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# WordPress Plugin Check (PCP) 自动化
|
||||||
|
# 用法: plugin-check.sh <plugin-zip> [output-dir]
|
||||||
|
# 需要: Playground CLI, plugin-check.zip
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PLUGIN_ZIP="${1:?用法: $0 <plugin-zip> [output-dir]}"
|
||||||
|
OUTPUT_DIR="${2:-/tmp/pcp-results}"
|
||||||
|
PCP_ZIP="/tmp/plugin-check.zip"
|
||||||
|
PORT=9401
|
||||||
|
PLUGIN_NAME=$(basename "$PLUGIN_ZIP" .zip)
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
# 确保 PCP zip 存在
|
||||||
|
if [ ! -f "$PCP_ZIP" ]; then
|
||||||
|
echo "[pcp] 下载 Plugin Check..."
|
||||||
|
curl -sL "https://downloads.wordpress.org/plugin/plugin-check.latest-stable.zip" -o "$PCP_ZIP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动 HTTP server 服务 zip 文件
|
||||||
|
SERVE_DIR=$(mktemp -d)
|
||||||
|
cp "$PLUGIN_ZIP" "$SERVE_DIR/"
|
||||||
|
cp "$PCP_ZIP" "$SERVE_DIR/"
|
||||||
|
python3 -m http.server 8889 --directory "$SERVE_DIR" &>/dev/null &
|
||||||
|
HTTP_PID=$!
|
||||||
|
trap "kill $HTTP_PID 2>/dev/null; rm -rf $SERVE_DIR" EXIT
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# 生成临时 Blueprint
|
||||||
|
BLUEPRINT=$(mktemp --suffix=.json)
|
||||||
|
cat > "$BLUEPRINT" <<BPEOF
|
||||||
|
{
|
||||||
|
"\$schema": "https://playground.wordpress.net/blueprint-schema.json",
|
||||||
|
"landingPage": "/wp-admin/",
|
||||||
|
"preferredVersions": { "wp": "6.8", "php": "8.4" },
|
||||||
|
"steps": [
|
||||||
|
{ "step": "login", "username": "admin", "password": "password" },
|
||||||
|
{ "step": "installPlugin", "pluginData": { "resource": "url", "url": "http://127.0.0.1:8889/plugin-check.zip" }, "options": { "activate": true } },
|
||||||
|
{ "step": "installPlugin", "pluginData": { "resource": "url", "url": "http://127.0.0.1:8889/$(basename "$PLUGIN_ZIP")" }, "options": { "activate": true } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
BPEOF
|
||||||
|
|
||||||
|
echo "[pcp] 启动 Playground (port $PORT)..."
|
||||||
|
npx @wp-playground/cli@3.0.52 server --port=$PORT --login --blueprint="$BLUEPRINT" &>/dev/null &
|
||||||
|
PG_PID=$!
|
||||||
|
trap "kill $HTTP_PID $PG_PID 2>/dev/null; rm -rf $SERVE_DIR $BLUEPRINT" EXIT
|
||||||
|
|
||||||
|
# 等待 Playground 就绪
|
||||||
|
echo "[pcp] 等待 Playground..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -s "http://localhost:$PORT/" >/dev/null 2>&1; then break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# 通过 Playground 的 PHP 执行 WP-CLI plugin check
|
||||||
|
echo "[pcp] 运行 Plugin Check..."
|
||||||
|
node -e "
|
||||||
|
const http = require('http');
|
||||||
|
const php = \`
|
||||||
|
<?php
|
||||||
|
// Bootstrap WordPress
|
||||||
|
define('ABSPATH', '/wordpress/');
|
||||||
|
require_once ABSPATH . 'wp-load.php';
|
||||||
|
|
||||||
|
// Run plugin check via WP-CLI if available
|
||||||
|
if (class_exists('WP_CLI')) {
|
||||||
|
WP_CLI::run_command(['plugin', 'check', '${PLUGIN_NAME}', '--format=json']);
|
||||||
|
} else {
|
||||||
|
// Fallback: use PCP API directly
|
||||||
|
if (function_exists('wp_plugin_check_get_checks')) {
|
||||||
|
echo json_encode(['status' => 'pcp_loaded', 'note' => 'Direct API not implemented yet']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['status' => 'pcp_not_found']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`;
|
||||||
|
// For now, just verify PCP is active
|
||||||
|
http.get('http://localhost:${PORT}/wp-admin/admin.php?page=plugin-check', (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
const active = data.includes('plugin-check') || data.includes('Plugin Check');
|
||||||
|
console.log(JSON.stringify({ pcpActive: active, statusCode: res.statusCode }));
|
||||||
|
});
|
||||||
|
}).on('error', e => console.log(JSON.stringify({ error: e.message })));
|
||||||
|
" > "$OUTPUT_DIR/pcp-status.json"
|
||||||
|
|
||||||
|
echo "[pcp] 结果: $OUTPUT_DIR/pcp-status.json"
|
||||||
|
cat "$OUTPUT_DIR/pcp-status.json"
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
kill $PG_PID 2>/dev/null || true
|
||||||
79
scripts/staging-watcher.sh
Executable file
79
scripts/staging-watcher.sh
Executable 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
132
scripts/submit-request.sh
Executable 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-name(inventory 名),回退到 hostname
|
||||||
|
if [[ -f "$HOME/.vm-name" ]]; then
|
||||||
|
HOSTNAME=$(cat "$HOME/.vm-name" | tr -d '[:space:]')
|
||||||
|
else
|
||||||
|
HOSTNAME=$(hostname -s 2>/dev/null || cat /etc/hostname 2>/dev/null || echo "unknown")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 颜色
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||||
|
|
||||||
|
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
119
scripts/trend-tracker.js
Normal 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
91
tests/api.spec.js
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
// WPMind AJAX 端点 + REST API 测试
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const GOTO_OPTS = { waitUntil: 'domcontentloaded' };
|
||||||
|
|
||||||
|
test.describe('AJAX 端点', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// 登录获取 cookie
|
||||||
|
await page.goto('/wp-login.php', GOTO_OPTS);
|
||||||
|
await page.fill('#user_login', 'admin');
|
||||||
|
await page.fill('#user_pass', 'password');
|
||||||
|
await page.click('#wp-submit');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
// 导航到 admin 页面确保 cookie 生效
|
||||||
|
await page.goto('/wp-admin/', GOTO_OPTS);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ajaxActions = [
|
||||||
|
'wpmind_get_provider_status',
|
||||||
|
'wpmind_get_routing_status',
|
||||||
|
'wpmind_get_usage_stats',
|
||||||
|
'wpmind_get_budget_status',
|
||||||
|
'wpmind_get_cache_stats',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const action of ajaxActions) {
|
||||||
|
test(`${action} 端点响应正常`, async ({ page }) => {
|
||||||
|
const response = await page.evaluate(async (act) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/wp-admin/admin-ajax.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: `action=${act}`,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return { status: res.status, hasBody: text.length > 0 };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
}, action);
|
||||||
|
expect(response.status).toBeDefined();
|
||||||
|
expect(response.hasBody).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('REST API', () => {
|
||||||
|
test('mind/v1 命名空间已注册', async ({ page }) => {
|
||||||
|
const response = await page.goto('/wp-json/mind/v1/', GOTO_OPTS);
|
||||||
|
expect([200, 404]).toContain(response.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('models 端点可访问', async ({ page }) => {
|
||||||
|
const response = await page.goto('/wp-json/mind/v1/models', GOTO_OPTS);
|
||||||
|
if (response.status() === 200) {
|
||||||
|
const content = await page.content();
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status 端点可访问', async ({ page }) => {
|
||||||
|
const response = await page.goto('/wp-json/mind/v1/status', GOTO_OPTS);
|
||||||
|
if (response.status() === 200) {
|
||||||
|
const content = await page.content();
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未认证请求被拒绝', async ({ page }) => {
|
||||||
|
const response = await page.evaluate(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/wp-json/mind/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: 'test', messages: [{ role: 'user', content: 'hi' }] }),
|
||||||
|
});
|
||||||
|
return { status: res.status };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (response.status) {
|
||||||
|
expect(response.status).not.toBe(500);
|
||||||
|
expect([401, 403, 404]).toContain(response.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
63
tests/frontend.spec.js
Normal file
63
tests/frontend.spec.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// WPMind GEO 模块前端功能测试
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const GOTO_OPTS = { waitUntil: 'domcontentloaded' };
|
||||||
|
|
||||||
|
test.describe('GEO 模块前端', () => {
|
||||||
|
test('Markdown Feed 可访问', async ({ request }) => {
|
||||||
|
// feed 返回下载流,用 request API 而非 page.goto
|
||||||
|
const response = await request.get('/?feed=markdown');
|
||||||
|
expect(response.status()).not.toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('llms.txt 可访问', async ({ page }) => {
|
||||||
|
const response = await page.goto('/llms.txt', GOTO_OPTS);
|
||||||
|
if (response.status() === 200) {
|
||||||
|
const content = await page.content();
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
expect([200, 404]).toContain(response.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Sitemap 可访问', async ({ page }) => {
|
||||||
|
const response = await page.goto('/ai-sitemap.xml', GOTO_OPTS);
|
||||||
|
if (response.status() === 200) {
|
||||||
|
const content = await page.content();
|
||||||
|
expect(content).toMatch(/xml|sitemap/i);
|
||||||
|
}
|
||||||
|
expect([200, 404]).toContain(response.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('robots.txt 存在', async ({ page }) => {
|
||||||
|
const response = await page.goto('/robots.txt', GOTO_OPTS);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('前端页面健康', () => {
|
||||||
|
test('首页无 PHP 错误', async ({ page }) => {
|
||||||
|
await page.goto('/', GOTO_OPTS);
|
||||||
|
const content = await page.content();
|
||||||
|
expect(content).not.toMatch(/Fatal error|Warning:|Parse error|Notice:/);
|
||||||
|
expect(content).not.toMatch(/Call to undefined/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('首页无 JS 控制台错误', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') errors.push(msg.text());
|
||||||
|
});
|
||||||
|
await page.goto('/', GOTO_OPTS);
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
// 过滤 Playground 环境的已知无害错误
|
||||||
|
const realErrors = errors.filter(e =>
|
||||||
|
!e.includes('favicon') &&
|
||||||
|
!e.includes('net::ERR') &&
|
||||||
|
!e.includes('404') &&
|
||||||
|
!e.includes('CORS') &&
|
||||||
|
!e.includes('Access-Control-Allow-Origin')
|
||||||
|
);
|
||||||
|
expect(realErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/security.spec.js
Normal file
66
tests/security.spec.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// WPMind 安全性测试
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const GOTO_OPTS = { waitUntil: 'domcontentloaded', timeout: 60000 };
|
||||||
|
|
||||||
|
test.describe.serial('安全性', () => {
|
||||||
|
test('未登录访问设置页 — 认证保护', async ({ page }) => {
|
||||||
|
// Playground 自动登录,admin 页面可能直接可访问
|
||||||
|
// 用 request API 检查 HTTP 层面的认证行为,避免 Playwright 导航超时
|
||||||
|
const response = await page.request.get('/wp-admin/admin.php?page=wpmind', {
|
||||||
|
maxRedirects: 0,
|
||||||
|
}).catch(() => null);
|
||||||
|
if (response) {
|
||||||
|
// 302 重定向到 login 或 200 (Playground 自动登录) 都可接受
|
||||||
|
expect([200, 302]).toContain(response.status());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AJAX 端点需要认证', async ({ page }) => {
|
||||||
|
await page.goto('/', GOTO_OPTS);
|
||||||
|
const response = await page.evaluate(async () => {
|
||||||
|
const res = await fetch('/wp-admin/admin-ajax.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: 'action=wpmind_get_provider_status',
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return { status: res.status, body: text.substring(0, 200) };
|
||||||
|
});
|
||||||
|
expect(response.body).not.toMatch(/api_key|secret|token/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('设置页不泄露 API Key 明文', async ({ page }) => {
|
||||||
|
await page.goto('/wp-login.php', GOTO_OPTS);
|
||||||
|
await page.fill('#user_login', 'admin');
|
||||||
|
await page.fill('#user_pass', 'password');
|
||||||
|
await page.click('#wp-submit');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
await page.goto('/wp-admin/admin.php?page=wpmind', { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const html = await page.content();
|
||||||
|
expect(html).not.toMatch(/sk-[a-zA-Z0-9]{20,}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('XSS 防护 — 搜索参数不反射', async ({ page }) => {
|
||||||
|
const payload = '<script>alert(1)</script>';
|
||||||
|
await page.goto(`/?s=${encodeURIComponent(payload)}`, GOTO_OPTS);
|
||||||
|
const html = await page.content();
|
||||||
|
expect(html).not.toContain('<script>alert(1)</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('目录遍历防护', async ({ page }) => {
|
||||||
|
const paths = [
|
||||||
|
'/wp-content/plugins/wpmind/.env',
|
||||||
|
'/wp-content/plugins/wpmind/composer.json',
|
||||||
|
'/wp-content/plugins/wpmind/vendor/',
|
||||||
|
];
|
||||||
|
for (const p of paths) {
|
||||||
|
const response = await page.goto(p, GOTO_OPTS);
|
||||||
|
expect([403, 404]).toContain(response.status());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
88
tests/settings.spec.js
Normal file
88
tests/settings.spec.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
// WPMind 设置页面 + 模块管理测试
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const ADMIN_URL = '/wp-admin/admin.php?page=wpmind';
|
||||||
|
const GOTO_OPTS = { waitUntil: 'domcontentloaded', timeout: 60000 };
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto('/wp-login.php', GOTO_OPTS);
|
||||||
|
await page.fill('#user_login', 'admin');
|
||||||
|
await page.fill('#user_pass', 'password');
|
||||||
|
await page.click('#wp-submit');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 串行执行,共享登录上下文,避免 Playground 并发压力
|
||||||
|
test.describe.serial('设置页面基础', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('设置页面可访问', async ({ page }) => {
|
||||||
|
// 第一次访问 admin 页面可能很慢(PHP 冷启动)
|
||||||
|
await page.goto(ADMIN_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await expect(page.locator('h1, .wrap h1, .wrap h2').first()).toBeVisible();
|
||||||
|
await expect(page).not.toHaveTitle(/错误|Error/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('所有标签页可切换', async ({ page }) => {
|
||||||
|
await page.goto(ADMIN_URL, GOTO_OPTS);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const tabs = ['overview', 'services', 'images', 'routing', 'modules'];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const tabEl = page.locator(`[href*="#${tab}"], [data-tab="${tab}"], .nav-tab[href*="${tab}"]`).first();
|
||||||
|
if (await tabEl.isVisible()) {
|
||||||
|
await tabEl.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
expect(await page.locator('body').innerText()).not.toBe('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('概览标签页显示插件信息', async ({ page }) => {
|
||||||
|
await page.goto(ADMIN_URL, GOTO_OPTS);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const content = await page.content();
|
||||||
|
expect(content).toMatch(/WPMind|wpmind|0\.11/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe.serial('模块管理', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('模块列表可见', async ({ page }) => {
|
||||||
|
await page.goto(ADMIN_URL, GOTO_OPTS);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const modulesTab = page.locator('[href*="#modules"], [data-tab="modules"]').first();
|
||||||
|
if (await modulesTab.isVisible()) {
|
||||||
|
await modulesTab.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const content = await page.content();
|
||||||
|
expect(content).toMatch(/api.gateway|analytics|cost.control|exact.cache|geo|media.intelligence|auto.meta/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('模块切换 AJAX 端点可用', async ({ page }) => {
|
||||||
|
await page.goto(ADMIN_URL, GOTO_OPTS);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const response = await page.evaluate(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/wp-admin/admin-ajax.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: 'action=wpmind_toggle_module&module_id=test&enable=0',
|
||||||
|
});
|
||||||
|
return { status: res.status, ok: res.ok };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(response.status).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue