Files
Agent-System-Dev/scripts/e2e.sh
T
weichen 6160dae6fb Merge current main into versioned dependency workspace
Why: Include the latest Dev task-card closure and governance fix while preserving the versioned dependency migration.
Evidence: SELF merge conflict inspection, clean staged diff, bash syntax check, and prior isolated E2E replay; full final-head verification pending.
Impact: Keep existing Backend and Frontend source directories outside the Git index and retain their on-disk Git data. Preserve password setup, audit grants, scoped browser coverage, and owned process cleanup.
Refs: #537, #538, mq-dev-20261014-537-01
2026-10-14 05:11:13 +00:00

329 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# e2e.sh —— U4 运行验收一条龙(真实浏览器:Playwright × RDM 审计工作区)。
#
# 动线(与 Frontend e2e/rdm-audit.playwright.ts 的环境契约一一对应):
# 1. 构建:版本化候选从固定 Dev 组合取得 RDM、同源基座 CLI 与 Frontend dist;
# 历史候选继续按原 gitlink 和 pins 路径物化。
# 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|--build-only]
# --seed-only 只做构建+种子+起服务(留端口与路径在 stdout,供人工/调试)
# --build-only 只构建固定产物;不建号、不起进程、不运行浏览器或模型验收
# 环境变量:
# RDM_REPO RDM 仓路径(默认 <本仓上级>/Agent-System-RDM)
# E2E_API_PORT/E2E_UI_PORT 服务端口(默认 18791/4173)
# E2E_OWNER/E2E_PASSWORD 登录账号(默认 e2e-auditor/E2eAudit#2026)
# E2E_PASSWORD2 首次登录后的设密结果(默认 E2eAudit#2026b)
# 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
BUILD_ONLY=0
case "${1:-}" in
--seed-only) SEED_ONLY=1 ;;
--build-only) BUILD_ONLY=1 ;;
"") ;;
*) echo "usage: scripts/e2e.sh [--seed-only|--build-only]" >&2; exit 2 ;;
esac
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}
PASSWORD2=${E2E_PASSWORD2:-E2eAudit#2026b}
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"
[ "$(git -C "$RDM_REPO" rev-parse --is-inside-work-tree 2>/dev/null)" = true ] \
|| die "RDM repo not found at $RDM_REPO (set RDM_REPO)"
if [ "$BUILD_ONLY" != 1 ]; then
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
fi
API_PID=""
UI_PID=""
cleanup() {
if [ -n "${API_PID:-}" ]; then kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; fi
# Preview is a single owned Vite process. Check its command before signaling
# so a recycled PID cannot stop a process from another worktree.
if [ -n "${UI_PID:-}" ] && ps -p "$UI_PID" -o args= 2>/dev/null | rg -Fq -- "vite preview --outDir $FRONTEND_DIST --port $UI_PORT"; then
kill "$UI_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
# ── 1. 构建 ─────────────────────────────────────────────
RDM_SHA=$(git -C "$RDM_REPO" rev-parse origin/main) || die "no origin/main in RDM repo"
RDM_MODULE=$(git -C "$RDM_REPO" show "$RDM_SHA:go.mod" | awk '$1 == "module" { print $2; exit }')
if [ -f "$DEV_ROOT/components.json" ] && [ "$RDM_MODULE" = "git-ssh.u1680832.nyat.app/weichen/Agent-System-RDM.git" ]; then
log "build versioned RDM and matching base CLI through the fixed Dev composition"
source "$DEV_ROOT/scripts/svc.sh"
RUNTIME="$OUT"
ENV_NAME=e2e-build
RDM_SRC_DIR="$RDM_REPO"
COMPOSITION_ROOT="$DEV_ROOT"
RDM_WT=$(materialize_rdm_commit "$RDM_SHA" "${RDM_SHA:0:12}") || die "versioned RDM composition failed"
cp "$RDM_WT/bin/agent-system-rdm" "$OUT/agent-system-rdm"
cp "$RDM_WT/bin/agent-system-admin" "$OUT/agent-system"
# Prepare the owner-controlled fixed source, then keep npm/cache/browser files
# in this run's independent archive, rather than modifying its .workspace.
python3 "$DEV_ROOT/scripts/workspace.py" --root "$DEV_ROOT" prepare --name Agent-System-Frontend >/dev/null || die "fixed Frontend preparation failed"
python3 "$DEV_ROOT/scripts/workspace.py" --root "$DEV_ROOT" verify --name Agent-System-Frontend >/dev/null || die "Frontend differs from fixed source"
FRONTEND_PREPARED=$(python3 "$DEV_ROOT/scripts/component-source.py" Agent-System-Frontend) || die "Frontend source identity unavailable"
FRONTEND_DIR="$OUT/frontend-src"
mkdir -p "$FRONTEND_DIR"
git -C "$FRONTEND_PREPARED" archive HEAD | tar -x -C "$FRONTEND_DIR"
python3 - "$DEV_ROOT" "$FRONTEND_DIR" <<'NPM_ENV'
import importlib.util, pathlib, subprocess, sys
root = pathlib.Path(sys.argv[1]); sys.path.insert(0, str(root / "scripts"))
from workspace.cli import process_environment
spec = importlib.util.spec_from_file_location("composer", root / "scripts/build-composition.py")
composer = importlib.util.module_from_spec(spec); spec.loader.exec_module(composer)
environment = composer.execution_environment(process_environment(root))
sys.exit(subprocess.run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"], cwd=sys.argv[2], env=environment).returncode)
NPM_ENV
[ "$?" = 0 ] || die "credential-free npm acquisition failed"
make -C "$DEV_ROOT" frontend >/dev/null || die "fixed frontend build failed"
FRONTEND_DIST="$DEV_ROOT/bin/frontend-dist"
else
# Historical RDM pins and the old Dev layout retain their former build route.
if [ -f "$DEV_ROOT/components.json" ]; then
die "historical RDM e2e requires its historical Dev candidate; no mixed-layout success is claimed"
fi
FRONTEND_DIR="$DEV_ROOT/modules/frontend"
FRONTEND_DIST="$FRONTEND_DIR/dist"
# ── 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 -
(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"
fi
[ -x "$OUT/agent-system-rdm" ] && [ -x "$OUT/agent-system" ] && [ -f "$FRONTEND_DIST/index.html" ] || die "complete e2e build artifacts are missing"
if [ "$BUILD_ONLY" = 1 ]; then
log "build-only complete; runtime/browser/model acceptance NOT_RUN; artifacts=$OUT"
exit 0
fi
# ── 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() {
local i
for i in $(seq 1 20); do
if curl -sf -c "$JAR" -X POST "$API_BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"owner\":\"$OWNER\",\"password\":\"$PASSWORD\"}" >/dev/null; then
return 0
fi
sleep 0.25
done
die "login failed"
}
finish_password_gate() {
curl -sf -b "$JAR" -c "$JAR" -X POST "$API_BASE/api/auth/password" \
-H 'Content-Type: application/json' \
-d "{\"next\":\"$PASSWORD2\"}" >/dev/null || die "password setup failed"
PASSWORD=$PASSWORD2
login
}
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
finish_password_gate
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: grant explicit audit.read for the fixture owner"
cmd rdm.perm.cap '{"action":"audit.read","allow":true}' >/dev/null
cmd rdm.perm.set "{\"projectId\":\"$PID_MAIN\",\"member\":\"$OWNER\",\"action\":\"audit.read\",\"allow\":true}" >/dev/null
cmd rdm.perm.set "{\"projectId\":\"$PID_ISO\",\"member\":\"$OWNER\",\"action\":\"audit.read\",\"allow\":true}" >/dev/null
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 $FRONTEND_DIR && 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 "$FRONTEND_DIR"
export PLAYWRIGHT_API_TARGET="$API_BASE"
exec ./node_modules/.bin/vite preview --outDir "$FRONTEND_DIST" --port "$UI_PORT" --strictPort
) >"$OUT/preview.log" 2>&1 &
UI_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 "$FRONTEND_DIR" && npx playwright install chromium >/dev/null 2>&1 || true)
log "run specs"
cd "$FRONTEND_DIR"
export PLAYWRIGHT_BASE_URL="http://127.0.0.1:$UI_PORT" E2E_OWNER="$OWNER" \
E2E_PASSWORD="${E2E_PASSWORD:-E2eAudit#2026}" E2E_PASSWORD2="$PASSWORD2" \
E2E_PROJECT_MAIN="$PROJ_MAIN" E2E_PROJECT_ISOLATED="$PROJ_ISO"
set +e
npx playwright test e2e/rdm-audit.playwright.ts --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/)"
# ── 4. 普通基座缺能力场景(增量 2 验收)──────────────────
# 同一 $DATA 换基座 CLI 服务(与 RDM 二进制同源同 pin,命令面无 rdm.inspect.*),
# preview 不动只换 /api 指向,跑 plain-base spec:审计入口隐藏、代理常驻、
# 全程无 rdm.* 命令请求——消除「入口可达、进入即 400」的原缺陷路径。
log "plain-base phase: serve base CLI (no RDM commands)"
stop_serve
"$OUT/agent-system" serve --data "$DATA" --addr "127.0.0.1:$API_PORT" --auth on \
>"$OUT/serve-plain.log" 2>&1 &
API_PID=$!
for i in $(seq 1 40); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$API_BASE/api/me" || echo 000)
[ "$CODE" != "000" ] && break
sleep 0.25
[ "$i" = 40 ] && { tail -5 "$OUT/serve-plain.log"; die "plain api not ready"; }
done
set +e
E2E_PLAIN_BASE=1 npx playwright test e2e/plain-base.playwright.ts e2e/changepw-form.playwright.ts --reporter=list >"$OUT/playwright-plain.log" 2>&1
PW_PLAIN_EXIT=$?
set -e
cat "$OUT/playwright-plain.log"
[ "$PW_PLAIN_EXIT" = 0 ] && log "PASS (plain-base + changepw-form)" || die "plain-base playwright exit=$PW_PLAIN_EXIT (log: $OUT/playwright-plain.log)"
log "done; artifacts in $OUT"