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