#!/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/;.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 → 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"