Add the duties acceptance e2e harness and record results

Why: the S9 acceptance corrections left no repeatable harness for the
duty-flows browser verification, and the doc-level in-flight confirmation
path had never been driven in a real browser; the legacy e2e.sh seeding
predates the login-key credential contract.

Evidence: bash scripts/e2e-duties.sh end-to-end PASS — granted non-admin
preview 200, in-flight round + reviewer change surfaces the confirmation
checkbox and a confirmed save invalidates the doc submission, idempotent
re-save applied=0 unchanged=1, unauthorized member preview 400,
void-legacy then doc submit passes (evidence row in this card).

Impact: scripts-only; no product code or pins change.
Refs: S9 acceptance report P0-B
This commit is contained in:
2026-10-16 04:22:09 +00:00
parent ae050e1358
commit 9e54b51b9a
2 changed files with 304 additions and 7 deletions
+294
View File
@@ -0,0 +1,294 @@
#!/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/<ts>)。账号固定
# 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:-}" ] && ps -p "$UI_PID" -o args= 2>/dev/null | grep -Fq -- "vite preview --port $UI_PORT"; 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 <user> <password>: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 <jar> <id> <params> → 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: $(curl -s -b "$AJ" "$API_BASE/api/me" | head -c 160) | 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"