Files
Agent-System-Dev/scripts/e2e.sh
T

311 lines
17 KiB
Bash
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.spec.ts 的环境契约一一对应):
# 1. 构建:RDM 二进制(RDM 仓 origin/main 临时 worktree,pins 物化)
# + 基座 CLI(本 worktree modules/backend,建号用)
# + frontend dist(本 worktree modules/frontend)
# 2. 种子:RDM -bootstrap-admin(域管理员)→ serve 首启建基座存储 → identity ensure →
# 基座 user bootstrap-admin(login-key 激活契约;--password 已被拒绝)→
# redeem+设密 → 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_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
[ "${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}
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)"
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; wait "$API_PID" 2>/dev/null || true; }
# Preview runs directly as one owned Vite process. Verify its command before
# signaling: a recycled PID must never stop another worktree's process.
if [ -n "${UI_PID:-}" ] && ps -p "$UI_PID" -o args= 2>/dev/null | rg -Fq -- "vite preview --port $UI_PORT"; then
kill "$UI_PID" 2>/dev/null || true
fi
}
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 (account bootstrap/activation) 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
# ── 凭据契约(2026-10-13 起):--password 建号被 RECOVERY_CONTRACT_REPLACED 拒绝,
# 改走 login-key 激活:serve 首启创建基座存储 → identity ensure → 基座首位账号
# bootstrap-admin(激活 Key 交付)→ API redeem → 设密(PASSWORD2 为登录密码)。
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"
}
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"
log "seed: identity ensure + base first account (login-key 契约)"
ID_OK=0
for i in $(seq 1 15); do
if "$OUT/agent-system" identity ensure --data "$DATA" --instance-id "e2e-audit" >/dev/null 2>&1; then ID_OK=1; break; fi
sleep 1 # 服务端受控迁移(含 identity 表)可能晚于 HTTP 就绪
done
[ "$ID_OK" = 1 ] || { tail -5 "$OUT/serve.log"; die "identity ensure failed"; }
"$OUT/agent-system" user bootstrap-admin --data "$DATA" --owner "$OWNER" \
--name "E2E 审计员" --out-file "$OUT/$OWNER.bootstrap.key" >/dev/null \
|| die "base bootstrap-admin failed"
K=$(cat "$OUT/$OWNER.bootstrap.key")
curl -sf -c "$JAR" -X POST "$API_BASE/api/auth/login-key/redeem" \
-H 'Content-Type: application/json' -d "{\"key\":\"$K\"}" >/dev/null || die "login-key redeem failed"
# 激活会话仅能设密:设为 PASSWORD2(后续登录与规格消费一致)。
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
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"
# I1 requires a current business grant even for a project creator or system
# administrator. Both projects are ordinary test fixtures in this private data root.
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"; }
log "seed: load cockpit doc-tree into $PID_MAIN"
"$OUT/agent-system-rdm" -data "$DATA" \
-structure "$DEV_ROOT/docs/structure/cockpit-e2e.yaml" -project "$PID_MAIN" \
>"$OUT/structure-cockpit.log" 2>&1 || { tail -5 "$OUT/structure-cockpit.log"; die "cockpit 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"
# ── 驾驶舱 I4 种子:文档树 + 章节正文(草稿流)+ 非成员账号 ──
log "seed: cockpit section content via draft propose+adopt"
COCKPIT_OV=$(cmd rdm.cockpit.overview "{\"projectId\":\"$PID_MAIN\"}")
DOC_ID=$(printf '%s' "$COCKPIT_OV" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i["ref"]["id"] for i in d["documents"] if i["title"]=="验证主体报告"][0])')
SEC_JSON=$(cmd rdm.cockpit.tree "{\"projectId\":\"$PID_MAIN\",\"parent\":\"$DOC_ID\"}")
SEC_OVERVIEW=$(printf '%s' "$SEC_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i["ref"]["id"] for i in d["items"] if i["title"]=="概述"][0])')
SEC_DESIGN=$(printf '%s' "$SEC_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print([i["ref"]["id"] for i in d["items"] if i["title"]=="设计说明"][0])')
PROP=$(cmd rdm.draft.propose "{\"projectId\":\"$PID_MAIN\",\"sectionId\":\"$SEC_OVERVIEW\",\"content\":\"本报告给出验证主体、结论与证据链的总体视图。\"}")
PROP_ID=$(printf '%s' "$PROP" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("proposalId") or d.get("id"))')
cmd rdm.draft.adopt "{\"projectId\":\"$PID_MAIN\",\"proposalId\":\"$PROP_ID\"}" >/dev/null
PROP=$(cmd rdm.draft.propose "{\"projectId\":\"$PID_MAIN\",\"sectionId\":\"$SEC_DESIGN\",\"content\":\"设计说明:两树一网领域事实经驾驶舱只读投影呈现。\"}")
PROP_ID=$(printf '%s' "$PROP" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("proposalId") or d.get("id"))')
cmd rdm.draft.adopt "{\"projectId\":\"$PID_MAIN\",\"proposalId\":\"$PROP_ID\"}" >/dev/null
# 场景 4 驱动用:留一个未采纳提案(浏览器内经 API 采纳后观察投影一致)。
cmd rdm.draft.propose "{\"projectId\":\"$PID_MAIN\",\"sectionId\":\"$SEC_DESIGN\",\"content\":\"一致性探针内容。\"}" >/dev/null
log "seed: second account (non-member negative case)"
"$OUT/agent-system" user create --data "$DATA" --owner e2e-reader --out-file "$OUT/e2e-reader.bootstrap.key" >/dev/null || die "reader create failed"
K2=$(cat "$OUT/e2e-reader.bootstrap.key")
JAR2="$OUT/reader.jar"
curl -sf -c "$JAR2" -X POST "$API_BASE/api/auth/login-key/redeem" -H 'Content-Type: application/json' -d "{\"key\":\"$K2\",\"owner\":\"e2e-reader\"}" >/dev/null || die "reader redeem failed"
curl -sf -b "$JAR2" -c "$JAR2" -X POST "$API_BASE/api/auth/password" -H 'Content-Type: application/json' -d "{\"next\":\"E2eReader#2026\"}" >/dev/null || die "reader password setup failed"
# ── 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"
export PLAYWRIGHT_API_TARGET="$API_BASE"
exec ./node_modules/.bin/vite preview --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 "$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="${E2E_PASSWORD:-E2eAudit#2026}" E2E_PASSWORD2="$PASSWORD2" \
E2E_PROJECT_MAIN="$PROJ_MAIN" E2E_PROJECT_ISOLATED="$PROJ_ISO"
set +e
# 本脚本的种子只覆盖审计工作区。u7 现场验收用例依赖另一套入口,不在这里跑。
# admin-create 场景复用本种子的基座管理员账号(bootstrap-admin)。
npx playwright test e2e/rdm-audit.playwright.ts e2e/admin-user-create.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"