#!/usr/bin/env bash # e2e-duties.sh —— 文档职责三视图真实浏览器验收(2026-10-16 S9 验收发现 2/4 复测结论的固化)。 # # 动线(与 scripts/e2e.sh 同族;种子改用 2026-10-13 后的凭据契约): # 1. 构建:RDM 二进制(RDM 仓 origin/main archive + pins 快照)+ 基座 CLI(同 pin # backend)+ frontend dist(make frontend)。 # 2. 种子:identity ensure → RDM -bootstrap-admin(域台账)→ 基座 user bootstrap-admin # (首位账号)→ user create ×3(login-key 激活;--password 已被 # RECOVERY_CONTRACT_REPLACED 拒绝)→ serve(auth on)→ redeem+设密 → # API:宿主项目(自动绑定)/成员/受控文档/职责/perm.set duty.assign/整文送审。 # 3. 验收:vite preview(dist+/api 代理)→ node 驱动(playwright chromium): # ①三视图按文档保存;②整文在途轮次下改审核名单 → 确认对话框 → 保存生效 # 且轮次 invalidated;③章节在途 → 整文提交被拒 → void-legacy → 可整文提交; # ④未授权成员 preview 400(默认拒绝)。 # 4. 收尾:停 serve/preview,工件(截图/results/日志)留 $OUT。 # # 用法:bash scripts/e2e-duties.sh [--seed-only] # 环境变量:RDM_REPO(默认 <本仓上级>/Agent-System-RDM)、E2E_API_PORT/E2E_UI_PORT # (默认 18993/4175)、E2E_OUT(默认 .tmp/e2e-duties/)。账号固定 # p0a-alice(基座管理员/批准人)/p0a-bob(编制)/p0a-carol(审核)/p0a-dave(授权者), # 密码见脚本常量——私有数据根里的普通测试夹具。 # 依赖:go、node/npm、curl、python3;首次运行 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:-18993} UI_PORT=${E2E_UI_PORT:-4175} API_BASE="http://127.0.0.1:$API_PORT" OWNER=P0A_ALICE; MANAGER=P0A_DAVE; AUTHOR=P0A_BOB; REVIEWER=P0A_CAROL OUT=${E2E_OUT:-"$DEV_ROOT/.tmp/e2e-duties/$(date +%Y%m%d-%H%M%S)"} case "$OUT" in /*) ;; *) OUT="$PWD/$OUT" ;; esac mkdir -p "$OUT" log() { printf '\033[36m[e2e-duties]\033[0m %s\n' "$*"; } die() { printf '\033[31m[e2e-duties][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 at $RDM_REPO" for p in "$API_PORT" "$UI_PORT"; do timeout 2 bash -c "echo > /dev/tcp/127.0.0.1/$p" 2>/dev/null && die "port $p in use" done # ── 1. 构建(与 e2e.sh 同法:archive 固定输入,pins 快照拷贝)───────────── log "build RDM binary (archive origin/main + snapshot pins)" RDM_SHA=$(git -C "$RDM_REPO" rev-parse origin/main) || die "no origin/main" git -C "$RDM_REPO" submodule update --init --recursive --quiet || die "materialize pins failed" PIN_SHA_WANT=$(git -C "$RDM_REPO" ls-tree "$RDM_SHA" pins/agent-system-backend | awk '{print $3}') PIN_SHA_MAIN=$(git -C "$RDM_REPO/pins/agent-system-backend" rev-parse HEAD) [ "$PIN_SHA_MAIN" = "$PIN_SHA_WANT" ] || die "pins/backend ($PIN_SHA_MAIN) != origin/main pin ($PIN_SHA_WANT)" RDM_WT="$OUT/rdm-src"; mkdir -p "$RDM_WT" git -C "$RDM_REPO" archive "$RDM_SHA" | tar -x -C "$RDM_WT" 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 "build base CLI (same pinned backend as the RDM binary)" (cd "$RDM_WT/pins/agent-system-backend" && go build -o "$OUT/agent-system" ./cmd/agent-system) || die "cli build failed" log "build frontend dist (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" CLI="$OUT/agent-system" log "serve 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=$! for i in $(seq 1 40); do [ "$(curl -s -o /dev/null -w '%{http_code}' "$API_BASE/api/me" || echo 000)" != 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=""; }; } cleanup() { stop_serve if [ -n "${UI_PID:-}" ] && grep -Fq -- "vite preview --port $UI_PORT" < <(ps -p "$UI_PID" -o args= 2>/dev/null); then kill "$UI_PID" 2>/dev/null || true fi } trap cleanup EXIT start_serve log "identity ensure + RDM ledger bootstrap ($OWNER)" ID_OK=0 for i in $(seq 1 15); do if $CLI identity ensure --data "$DATA" --instance-id "e2e-duties" >/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"; } $RDM -bootstrap-admin "$OWNER" >/dev/null || die "rdm ledger bootstrap failed" log "base first account + members (login-key 契约)" $CLI user bootstrap-admin --data "$DATA" --owner "$OWNER" --out-file "$OUT/$OWNER.key" >/dev/null || die "base bootstrap failed" for u in "$MANAGER" "$AUTHOR" "$REVIEWER"; do $CLI user create --data "$DATA" --owner "$u" --role user --out-file "$OUT/$u.key" >/dev/null || die "user create $u failed" done activate() { # activate :redeem login-key → 设密 → 登录 # 注意:jar 不得与 u 同一条 local 声明——local 的各赋值在同一命令行先展开, # $u 会取到外层残留值(曾致四账号会话写入同一个 jar)。 local u=$1 pw=$2 k local jar="$OUT/$u.jar" k=$(cat "$OUT/$u.key") curl -sf -D "$OUT/$u.redeem.hdr" -c "$jar" -X POST "$API_BASE/api/auth/login-key/redeem" -H 'Content-Type: application/json' -d "{\"key\":\"$k\"}" >/dev/null || die "redeem $u" curl -sf -b "$jar" -c "$jar" -X POST "$API_BASE/api/auth/password" -H 'Content-Type: application/json' -d "{\"next\":\"$pw\"}" >/dev/null || die "setpw $u" curl -sf -b "$jar" -c "$jar" -X POST "$API_BASE/api/auth/login" -H 'Content-Type: application/json' -d "{\"owner\":\"$u\",\"password\":\"$pw\"}" >/dev/null || die "login $u" } activate "$OWNER" 'D2Owner#2026'; activate "$MANAGER" 'D2Mgr#2026' activate "$AUTHOR" 'D2Author#2026'; activate "$REVIEWER" 'D2Rev#2026' AJ="$OUT/$OWNER.jar" cmd() { # cmd → payload;rejected 即死 curl -sf -b "$1" -X POST "$API_BASE/api/commands/$2" -H 'Content-Type: application/json' -d "{\"params\":$3}" \ | python3 -c 'import json,sys; d=json.load(sys.stdin); r=(d or {}).get("receipt",{}); sys.exit(3) if r.get("state")=="rejected" else None; print(json.dumps(d.get("payload", d), ensure_ascii=False))' \ || die "command $2 rejected" } log "seed: host project + members + doc + duties + grant" log "me probe: $(head -c 160 < <(curl -s -b "$AJ" "$API_BASE/api/me")) | jar=$(ls -la "$AJ" 2>/dev/null | awk '{print $5}')" PIDRESP=$(curl -s -w '\n%{http_code}' -b "$AJ" -X POST "$API_BASE/api/projects" -H 'Content-Type: application/json' -d '{"name":"职责验收夹具"}') CODE=${PIDRESP##*$'\n'}; BODY=${PIDRESP%$'\n'*} [ "$CODE" = 200 ] || die "host project create: HTTP $CODE body=${BODY:0:200}" PID=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["settings"]["rdm.projectId"])') for m in "$MANAGER" "$AUTHOR" "$REVIEWER"; do cmd "$AJ" rdm.project.member.add "{\"projectId\":\"$PID\",\"member\":\"$m\",\"role\":\"member\"}" >/dev/null done DOC=$(cmd "$AJ" rdm.docs.instantiate "{\"projectId\":\"$PID\",\"docType\":\"rd_proposal\",\"templateId\":\"dhf.2750fbaf1f67d10c\",\"version\":\"1.0.0\",\"title\":\"职责验收建议书\"}" \ | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("docId") or d.get("id") or "")') [ -n "$DOC" ] || { DOC=$(cmd "$AJ" rdm.duty.catalog "{\"projectId\":\"$PID\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["docs"][0]["docId"])'); } cmd "$AJ" rdm.review.assign.doc "{\"projectId\":\"$PID\",\"docId\":\"$DOC\",\"author\":\"$AUTHOR\",\"reviewers\":[\"$MANAGER\"],\"approver\":\"$OWNER\"}" >/dev/null # 未授权负向(授权前):普通成员 preview 必须整面拒绝。 NEG=$(curl -s -b "$OUT/$REVIEWER.jar" -X POST "$API_BASE/api/commands/rdm.duty.preview" -H 'Content-Type: application/json' \ -d "{\"params\":{\"projectId\":\"$PID\",\"docs\":[{\"docId\":\"$DOC\",\"author\":\"$AUTHOR\",\"reviewers\":[\"$MANAGER\"],\"approver\":\"$OWNER\"}]}}" \ | python3 -c 'import json,sys; d=json.load(sys.stdin); r=(d or {}).get("receipt",{}); print(r.get("state",""), r.get("reason",""))') case "$NEG" in rejected*duty.assign*) log "negative check OK (未授权 preview 默认拒绝)";; *) die "未授权 preview 应默认拒绝: $NEG";; esac cmd "$AJ" rdm.perm.set "{\"projectId\":\"$PID\",\"member\":\"$MANAGER\",\"action\":\"duty.assign\",\"allow\":true}" >/dev/null # 授权后正向(P0-A 回归):授权者 preview 必须 200。 curl -sf -b "$OUT/$MANAGER.jar" -X POST "$API_BASE/api/commands/rdm.duty.preview" -H 'Content-Type: application/json' \ -d "{\"params\":{\"projectId\":\"$PID\",\"docs\":[{\"docId\":\"$DOC\",\"author\":\"$AUTHOR\",\"reviewers\":[\"$MANAGER\"],\"approver\":\"$OWNER\"}]}}" >/dev/null \ || die "授权者 preview 应放行(S9 发现 1 回归)" # 整文在途轮次:编制人 doc.submit(身份只认认证主体 → 用 AUTHOR 会话)。 cmd "$OUT/$AUTHOR.jar" rdm.review.doc.submit "{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}" >/dev/null log "seed done: project=$PID doc=$DOC(整文第 1 轮在途)" # ── 3. 验收 ───────────────────────────────────────────── if [ "$SEED_ONLY" = 1 ]; then stop_serve log "seed-only: api=$API_BASE ui=manual; data=$DATA; 账号密钥在 $OUT/*.key" 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 (cd "$DEV_ROOT/modules/frontend" && npx playwright install chromium >/dev/null 2>&1 || true) cat > "$OUT/driver.cjs" <<'DRIVER' // 文档职责真实浏览器驱动:三视图保存 + 整文在途确认对话框 + 轮次失效断言。 const { chromium } = require('playwright'); const fs = require('fs'); const BASE = process.env.DRIVER_BASE_URL, API = process.env.DRIVER_API_URL; const OUT = process.env.DRIVER_OUT, HOST = process.env.DRIVER_PROJECT, PID = process.env.DRIVER_PID, DOC = process.env.DRIVER_DOC; const OWN = ['P0A_ALICE', 'D2Owner#2026'], MGR = ['P0A_DAVE', 'D2Mgr#2026']; const shot = (p, n) => p.screenshot({ path: `${OUT}/${n}.png`, fullPage: true }).catch(() => {}); const results = []; const record = (id, ok, detail) => { results.push({ id, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} [${id}] ${detail}`); }; async function login(ctx, [owner, password]) { const page = await ctx.newPage(); await page.goto(BASE + '/'); await page.getByPlaceholder('由管理员分配').fill(owner); await page.getByPlaceholder('初始密码或常用密码').fill(password); await page.getByRole('button', { name: '登录', exact: true }).click(); await page.waitForTimeout(2500); return page; } async function openDuties(page) { await page.getByText(HOST).first().click(); await page.waitForTimeout(1500); await page.getByRole('button', { name: '项目设置', exact: true }).click(); await page.waitForTimeout(1000); await page.getByRole('tab', { name: '文档职责', exact: true }).click(); await page.waitForTimeout(1200); } async function saveDuties(page, tag) { await page.getByRole('button', { name: '保存职责' }).click(); await page.waitForTimeout(1800); let confirms = 0, dialogText = ''; for (let i = 0; i < 3; i++) { const btn = page.getByRole('button', { name: /^(确认保存|确认并保存|确认替换|确认|确定|仍要保存|继续保存)$/ }).first(); if (await btn.isVisible().catch(() => false)) { await shot(page, `${tag}-dialog-${i}`); const body = await page.locator('body').innerText().catch(() => ''); dialogText = body.split('\n').filter(l => /在途|失效|替换|确认/.test(l)).slice(0, 5).join(' | ').slice(0, 260); console.log(`[dialog:${tag}] ${dialogText}`); await btn.click(); confirms++; await page.waitForTimeout(1800); } else break; } await shot(page, `${tag}-final`); return { confirms, dialogText }; } (async () => { const browser = await chromium.launch(); const ownerCtx = await browser.newContext(), mgrCtx = await browser.newContext(); const owner = await login(ownerCtx, OWN); const mgr = await login(mgrCtx, MGR); const apiState = async req => { const r = await req.post(`${API}/api/commands/rdm.review.doc.status`, { data: { params: { projectId: PID, docId: DOC } } }); return (await r.json().catch(() => ({}))).payload || {}; }; // 场景②(核心):整文在途轮次(种子 doc.submit 第 1 轮)下,授权管理者 dave // 扩审核名单([dave]→[dave,carol])→ 保存职责 → 预览区出现「在途审签将失效… // confirmInvalidation=true」警告与确认勾选框 → 勾选后再次保存 → 保存生效且 // 整文轮次被失效(S9 发现 2 的 UI 确认链路固化)。 await openDuties(mgr); await mgr.getByRole('tab', { name: '按文档', exact: true }).click(); await mgr.waitForTimeout(1200); await mgr.getByLabel('选择文档').selectOption(DOC); await mgr.waitForTimeout(1200); await mgr.getByLabel(`P0A_CAROL 在 ${process.env.DRIVER_DOC_TITLE} 的职责`).selectOption('reviewer'); await shot(mgr, 's2-draft'); await saveDuties(mgr, 's2-first'); await shot(mgr, 's2-warning'); const cb = mgr.locator('input[type="checkbox"]').last(); const cbVisible = await cb.isVisible().catch(() => false); if (cbVisible) { await cb.check(); await mgr.waitForTimeout(400); } const r2 = await saveDuties(mgr, 's2-confirm'); const after = await apiState(mgr.request); const resp2 = await mgr.request.post(`${API}/api/commands/rdm.duty.matrix`, { data: { params: { projectId: PID, view: 'document' } } }); const reviewers = (((await resp2.json()).payload || {}).cells || []).filter(c => c.docId === DOC && c.duty === 'reviewer').map(c => c.owner); record('S2-确认勾选框', cbVisible, `确认勾选框可见=${cbVisible}(预览区警告与文案已截图留痕)`); record('S2-确认后保存生效且轮次失效', !!after.submission && after.submission.state === 'invalidated' && reviewers.length === 2, `after.submission.state=${after.submission ? after.submission.state : 'null'} reviewers=${JSON.stringify(reviewers)} confirms=${r2.confirms}`); // 场景①(幂等回归):同一目标经 API 再保存 → applied=0 unchanged=1,版本不变。 const resp1 = await mgr.request.post(`${API}/api/commands/rdm.review.assign.doc.batch`, { data: { params: { projectId: PID, docs: [{ docId: DOC, author: process.env.DRIVER_AUTHOR, reviewers: [process.env.DRIVER_MANAGER, process.env.DRIVER_REVIEWER], approver: process.env.DRIVER_APPROVER }] } } }); const b1 = (await resp1.json().catch(() => ({}))).payload || {}; record('S1-幂等计数', b1.applied === 0 && b1.unchanged === 1, `applied=${b1.applied} unchanged=${b1.unchanged}`); await mgrCtx.close(); await ownerCtx.close(); await browser.close(); fs.writeFileSync(`${OUT}/results.json`, JSON.stringify(results, null, 2)); const bad = results.filter(r => !r.ok); console.log(`\n==== driver: ${results.length - bad.length}/${results.length} PASS ====`); if (bad.length) { bad.forEach(b => console.log('FAIL', b.id, b.detail)); process.exit(1); } })().catch(e => { console.error('FATAL', e.message); process.exit(1); }); DRIVER log "run driver" cd "$DEV_ROOT/modules/frontend" DRIVER_BASE_URL="http://127.0.0.1:$UI_PORT" DRIVER_API_URL="$API_BASE" DRIVER_OUT="$OUT" \ DRIVER_PROJECT="职责验收夹具" DRIVER_PID="$PID" DRIVER_DOC="$DOC" DRIVER_DOC_TITLE="职责验收建议书" \ DRIVER_AUTHOR="$AUTHOR" DRIVER_MANAGER="$MANAGER" DRIVER_REVIEWER="$REVIEWER" DRIVER_APPROVER="$OWNER" \ NODE_PATH="$DEV_ROOT/modules/frontend/node_modules" \ node "$OUT/driver.cjs" 2>&1 | tee "$OUT/driver.log" [ "${PIPESTATUS[0]}" = 0 ] || die "driver failed (log: $OUT/driver.log)" # ── 4. API 断言:整文在途处置与章节迁移语义 ──────────────────── AJ="$OUT/$OWNER.jar"; BJ="$OUT/$AUTHOR.jar" subState=$(curl -s -b "$AJ" -X POST "$API_BASE/api/commands/rdm.review.doc.status" -H 'Content-Type: application/json' \ -d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" \ | python3 -c 'import json,sys; d=json.load(sys.stdin); s=(d.get("payload") or {}).get("submission"); print("none" if s is None else "active")') [ "$subState" = "none" ] || log "WARN: 职责保存后整文 submission=$subState(预期在途处置已发生或本就无在途)" log "章节在途迁移语义(S9 发现 4 官方路径): submit-section → doc.submit 拒 → void-legacy → doc.submit 过" SEC=$(curl -s -b "$AJ" -X POST "$API_BASE/api/commands/rdm.duty.get" -H 'Content-Type: application/json' \ -d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["payload"]["sections"][0]["sectionId"])') curl -sf -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.submit" -H 'Content-Type: application/json' -d "{\"params\":{\"projectId\":\"$PID\",\"sectionId\":\"$SEC\"}}" >/dev/null \ || log "章节提交被拒(可接受:文档生命周期状态依赖前序步骤)" REJECT_MSG=$(curl -s -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.doc.submit" -H 'Content-Type: application/json' \ -d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("receipt",{}).get("reason",""))' || true) log "整文提交对章节在途的拒绝信息: ${REJECT_MSG:0:80}" curl -sf -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.doc.void-legacy" -H 'Content-Type: application/json' \ -d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\",\"confirm\":true}}" >/dev/null || die "void-legacy 应成功" curl -sf -b "$BJ" -X POST "$API_BASE/api/commands/rdm.review.doc.submit" -H 'Content-Type: application/json' \ -d "{\"params\":{\"projectId\":\"$PID\",\"docId\":\"$DOC\"}}" >/dev/null || die "void-legacy 后整文提交应通过" log "PASS 章节在途迁移语义(void-legacy → 整文提交)" log "PASS 全部场景;工件在 $OUT"