Files

295 lines
19 KiB
Bash
Raw Permalink 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-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:-}" ] && 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 <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: $(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"