Merge pull request 'U4 browser acceptance: Playwright harness, seed pipeline, and audit-workspace fixes (frontend pin bump)' (#393) from feature/rdm-e2e-acceptance into main
ci / test-pg (push) Successful in 1m3s
ci / test (push) Successful in 2m29s

This commit was merged in pull request #393.
This commit is contained in:
2026-10-11 18:17:39 +00:00
4 changed files with 547 additions and 1 deletions
+3
View File
@@ -16,3 +16,6 @@ runtime/
# Mimosa 安全扫描插件的逐会话状态(hook-status/reports/ledger,机器自动重建)
.mimosa/
# e2e.sh 运行工件(构建/种子/日志;scripts/e2e.sh 输出)
.tmp/
File diff suppressed because one or more lines are too long
Executable
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# e2e.sh —— U4 运行验收一条龙(真实浏览器:Playwright × RDM 审计工作区)。
#
# 动线(与 frontend/e2e/rdm-audit.spec.ts 的环境契约一一对应):
# 1. 构建:RDM 二进制(RDM 仓 origin/main 临时 worktree,pins 物化)
# + 基座 CLI(本 worktree modules/backend,建号用)
# + frontend dist(本 worktree modules/frontend)
# 2. 种子:RDM -bootstrap-admin(域管理员)→ 基座 user create(登录账号)
# → serve(auth on)→ API rdm.project.create×2 + rdm.ingest(主项目)
# 3. 验收:vite preview(dist+/api 代理)→ npx playwright test
# 4. 收尾:停 serve/preview,保存认证/失败工件到 $E2E_OUT
#
# 用法:bash scripts/e2e.sh [--seed-only]
# --seed-only 只做构建+种子+起服务(留端口与路径在 stdout,供人工/调试)
# 环境变量:
# RDM_REPO RDM 仓路径(默认 <本仓上级>/Agent-System-RDM)
# E2E_API_PORT/E2E_UI_PORT 服务端口(默认 18791/4173)
# E2E_OWNER/E2E_PASSWORD 登录账号(默认 e2e-auditor/E2eAudit#2026)
# E2E_PROJECT_MAIN/E2E_PROJECT_ISOLATED 项目名(默认 审计演示项目/隔离检查项目)
# E2E_OUT 工件目录(默认 .tmp/e2e/<ts>;.gitignore 已覆盖 .tmp)
# 依赖:go、node/npm、curl;首次运行会 npx playwright install chromium。
set -euo pipefail
DEV_ROOT=$(git -C "$(dirname "$0")/.." rev-parse --show-toplevel)
cd "$DEV_ROOT"
SEED_ONLY=0
[ "${1:-}" = "--seed-only" ] && SEED_ONLY=1
RDM_REPO=${RDM_REPO:-"$DEV_ROOT/../Agent-System-RDM"}
API_PORT=${E2E_API_PORT:-18791}
UI_PORT=${E2E_UI_PORT:-4173}
OWNER=${E2E_OWNER:-e2e-auditor}
PASSWORD=${E2E_PASSWORD:-E2eAudit#2026}
PROJ_MAIN=${E2E_PROJECT_MAIN:-审计演示项目}
PROJ_ISO=${E2E_PROJECT_ISOLATED:-隔离检查项目}
API_BASE="http://127.0.0.1:$API_PORT"
# 工件目录一律绝对路径(git -C / 后台进程的 cwd 解析不能依赖调用方位置)。
OUT=${E2E_OUT:-"$DEV_ROOT/.tmp/e2e/$(date +%Y%m%d-%H%M%S)"}
case "$OUT" in /*) ;; *) OUT="$PWD/$OUT" ;; esac
mkdir -p "$OUT"
log() { printf '\033[36m[e2e]\033[0m %s\n' "$*"; }
die() { printf '\033[31m[e2e][FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
command -v go >/dev/null || die "go not found"
command -v node >/dev/null || die "node not found"
command -v curl >/dev/null || die "curl not found"
[ -d "$RDM_REPO/.git" ] || die "RDM repo not found at $RDM_REPO (set RDM_REPO)"
for p in "$API_PORT" "$UI_PORT"; do
if timeout 2 bash -c "echo > /dev/tcp/127.0.0.1/$p" 2>/dev/null; then
die "port $p already in use (set E2E_API_PORT/E2E_UI_PORT)"
fi
done
# ── 1. 构建 ─────────────────────────────────────────────
# RDM 源码经 git archive 从本地对象导出(不建 worktree、不触远端——Gitea 抖动时
# submodule 克隆会挂死);pins/* 从 RDM 主检出的已物化 submodule 快照拷贝(cp 排除
# .git,39MB 级),构建输入由此固定为 RDM origin/main + 其 gitlink 指向的本地内容。
log "build RDM binary (archive origin/main + snapshot pins from local checkout)"
RDM_WT="$OUT/rdm-src"
RDM_SHA=$(git -C "$RDM_REPO" rev-parse origin/main) || die "no origin/main in RDM repo"
mkdir -p "$RDM_WT"
git -C "$RDM_REPO" archive "$RDM_SHA" | tar -x -C "$RDM_WT"
# 主检出 pins 对齐 gitlink(本地对象已有时秒回;此时才需要网络 fetch 一次)。
git -C "$RDM_REPO" submodule update --init --recursive --quiet \
|| die "materialize RDM pins failed (is the RDM checkout reachable?)"
PIN_SHA_MAIN=$(git -C "$RDM_REPO/pins/agent-system-backend" rev-parse HEAD)
PIN_SHA_WANT=$(git -C "$RDM_REPO" ls-tree "$RDM_SHA" pins/agent-system-backend | awk '{print $3}')
[ "$PIN_SHA_MAIN" = "$PIN_SHA_WANT" ] \
|| die "RDM checkout pins/backend ($PIN_SHA_MAIN) != origin/main pin ($PIN_SHA_WANT); git -C $RDM_REPO submodule update first"
mkdir -p "$RDM_WT/pins"
tar -C "$RDM_REPO/pins" --exclude=.git -cf - agent-system-backend | tar -C "$RDM_WT/pins" -xf -
cleanup() {
[ -n "${API_PID:-}" ] && kill "$API_PID" 2>/dev/null || true
# npx→node 的进程树用单 PID 杀不净:按命令行匹配兜底(本脚本专用端口)。
[ -n "${UI_PID:-}" ] && kill "$UI_PID" 2>/dev/null || true
pkill -f "vite preview --port $UI_PORT" 2>/dev/null || true
pkill -f "agent-system-rdm -data.*e2e" 2>/dev/null || true
}
trap cleanup EXIT
(cd "$RDM_WT" && go build -o "$OUT/agent-system-rdm" .) || die "rdm build failed"
log "rdm binary: $OUT/agent-system-rdm (RDM@${RDM_SHA:0:12}, pins@${PIN_SHA_WANT:0:12})"
log "build base CLI (user create) from the same pinned backend as the RDM binary"
# 版本一致性:建号 CLI 必须与被测 RDM 二进制共用同一基座 pin——用 Dev 顶层
# backend(main 最新)建号会把库迁到更高 schema,RDM pins(较旧)打不开。
(cd "$RDM_WT/pins/agent-system-backend" && go build -o "$OUT/agent-system" ./cmd/agent-system) \
|| die "backend cli build failed"
log "build frontend dist (contracts + ui deps + bundle, via make frontend)"
make -C "$DEV_ROOT" frontend >/dev/null || die "frontend build failed"
# ── 2. 种子 ─────────────────────────────────────────────
DATA="$OUT/data"
mkdir -p "$DATA"
RDM="$OUT/agent-system-rdm -data $DATA"
log "bootstrap: RDM domain admin = $OWNER"
$RDM -bootstrap-admin "$OWNER" >/dev/null
log "bootstrap: base account $OWNER (admin)"
"$OUT/agent-system" user create --data "$DATA" --owner "$OWNER" --name "E2E 审计员" \
--password "$PASSWORD" --admin >/dev/null
log "serve RDM at $API_BASE (auth on)"
start_serve() {
"$OUT/agent-system-rdm" -data "$DATA" -addr "127.0.0.1:$API_PORT" -auth on \
>"$OUT/serve.log" 2>&1 &
API_PID=$!
# auth on 时未登录 /api/me 返回 401——服务已就绪的信号是「有 HTTP 应答」而非 2xx。
for i in $(seq 1 40); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$API_BASE/api/me" || echo 000)
[ "$CODE" != "000" ] && return 0
sleep 0.25
[ "$i" = 40 ] && { tail -5 "$OUT/serve.log"; die "api not ready"; }
done
}
stop_serve() {
[ -n "${API_PID:-}" ] && { kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; API_PID=""; }
}
login() {
curl -sf -c "$JAR" -X POST "$API_BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"owner\":\"$OWNER\",\"password\":\"$PASSWORD\"}" >/dev/null || die "login failed"
}
cmd() { # cmd <id> <params-json> → payload(失败即死,种子步骤不容忍部分成功)
local id=$1 params=${2:-{\}}
curl -sf -b "$JAR" -X POST "$API_BASE/api/commands/$id" \
-H 'Content-Type: application/json' -d "{\"params\":$params}" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); r=(d or {}).get("receipt",{}); sys.exit(1) if r.get("state")=="rejected" else None; print(json.dumps(d.get("payload", d), ensure_ascii=False))' \
|| die "command $id rejected"
}
# 阶段①:建项目与需求台账(API;需 serve)。
start_serve
JAR="$OUT/cookies.txt"
login
log "seed: create projects"
PID_MAIN=$(cmd rdm.project.create "{\"name\":\"$PROJ_MAIN\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
PID_ISO=$(cmd rdm.project.create "{\"name\":\"$PROJ_ISO\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
log "seed: projects main=$PID_MAIN iso=$PID_ISO"
log "seed: import requirement ledger into $PID_MAIN"
cmd rdm.reqs.import "{\"projectId\":\"$PID_MAIN\",\"items\":$(
python3 - <<'PY'
import json
rows = [
("UR", "UR-01 离线可用", "断网时已有会话可继续浏览"),
("UR", "UR-02 权限收敛", "普通用户无审计入口,接口拒绝"),
("UR", "UR-03 取证效率", "追溯取证不超过三次主要点击"),
]
print(json.dumps([
{"kind": k, "title": t, "statement": s, "source": "01-用户需求与系统需求.md"}
for k, t, s in rows
], ensure_ascii=False))
PY
)}" >/dev/null
# 阶段②:CLI 直写库(-structure 项目归属装载;与 serve 不并发写 SQLite)。
stop_serve
log "seed: load doc-tree structure into $PID_MAIN"
"$OUT/agent-system-rdm" -data "$DATA" \
-structure "$RDM_WT/docs/structure/rdm-self.yaml" -project "$PID_MAIN" \
>"$OUT/structure.log" 2>&1 || { tail -5 "$OUT/structure.log"; die "structure load failed"; }
grep -o '装载(.*)' "$OUT/structure.log" | head -1 || true
# 阶段③:重启服务,切片入库挂载到已装载骨架(API)。
start_serve
login
SEED_MD='# 用户需求与系统需求
| UR-01 | 离线可用:断网时已有会话可继续浏览 |
| UR-02 | 权限收敛:普通用户无审计入口 |
| UR-03 | 追溯取证不超过三次主要点击 |
## 背景说明
审计工作区按项目隔离读取;管理员可跨项目目录进入。事件按操作归组,
历史模式按 seq 还原,缺失事实明确标注不可恢复。'
log "seed: ingest corpus into $PID_MAIN"
cmd rdm.ingest "{\"projectId\":\"$PID_MAIN\",\"source\":\"01-用户需求与系统需求.md\",\"stage\":\"BD\",\"text\":$(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<<"$SEED_MD")}" >/dev/null
log "seed done"
# ── 3. 验收 ─────────────────────────────────────────────
if [ "$SEED_ONLY" = 1 ]; then
log "seed-only mode: api=$API_BASE ui=next;cookie jar=$JAR;数据=$DATA"
log "preview: (cd modules/frontend && PLAYWRIGHT_API_TARGET=$API_BASE npx vite preview --port $UI_PORT)"
log "inspect: curl -b $JAR $API_BASE/api/commands/rdm.inspect.summary -X POST -H 'Content-Type: application/json' -d '{\"params\":{\"projectId\":\"$PID_MAIN\"}}'"
wait
exit 0
fi
log "start vite preview at http://127.0.0.1:$UI_PORT"
(cd "$DEV_ROOT/modules/frontend" && \
PLAYWRIGHT_API_TARGET="$API_BASE" npx vite preview --port "$UI_PORT" --strictPort \
>"$OUT/preview.log" 2>&1 &
echo $! > "$OUT/preview.pid")
UI_PID=$(cat "$OUT/preview.pid")
for i in $(seq 1 40); do
curl -sf -o /dev/null "http://127.0.0.1:$UI_PORT/" && break
sleep 0.25
[ "$i" = 40 ] && { tail -5 "$OUT/preview.log"; die "preview not ready"; }
done
log "playwright (install chromium if missing)"
(cd "$DEV_ROOT/modules/frontend" && npx playwright install chromium >/dev/null 2>&1 || true)
log "run specs"
cd "$DEV_ROOT/modules/frontend"
export PLAYWRIGHT_BASE_URL="http://127.0.0.1:$UI_PORT" E2E_OWNER="$OWNER" E2E_PASSWORD="$PASSWORD" \
E2E_PROJECT_MAIN="$PROJ_MAIN" E2E_PROJECT_ISOLATED="$PROJ_ISO"
set +e
npx playwright test --reporter=list >"$OUT/playwright.log" 2>&1
PW_EXIT=$?
set -e
cat "$OUT/playwright.log"
[ "$PW_EXIT" = 0 ] && log "PASS" || die "playwright exit=$PW_EXIT (log: $OUT/playwright.log, trace: $OUT/frontend/test-results/)"
log "done; artifacts in $OUT"