@@ -0,0 +1,439 @@
/**
* 真实浏览器端到端验证(第二篇):AI 预审 / 历史经验加工
*
* 第四批用 `browser-e2e.cjs` 走过 `/trajectory-center`,并把 F11 记为「部分关闭」:
* `/experience-center` 与 `/prescreen-workbench` 当时只验到「能渲染」,交互没有逐项点过。
* 本脚本补的就是这两个表面——它驱动的都是**真实点击**(发起预审、重建时间线、请求提炼、
* 展开依据抽屉、切换身份),断言取真实 DOM 与真实执行链的结果,不做接口 mock。
*
* **刻意不接入 `scripts/verify.py`**: verify 是本机随时可跑的强制门禁,不应依赖浏览器
* 是否存在。是否把它写进门禁是产品/工程决策(见 15 交接第 7 节 F27)。
*
* 依赖与用法:
* 1. 本机有 Chrome(或用 CHROME_BIN 指定路径)
* 2. npm i --no-save puppeteer-core # 不写入 package.json
* 3. 起后端:cd backend && XINGBANG_DEMO_ROLE_LOGIN=1 \
* venv/Scripts/python.exe -m uvicorn app:app --host 127.0.0.1 --port 8012
* 4. 起前端:RISK_AGENT_BACKEND_URL=http://127.0.0.1:8012 npm run dev -- -p 3212
* 5. node scripts/browser-e2e-modules.cjs # 截图与 result.json 落在 E2E_OUT
*
* 注意:脚本**真的写演示库**,不是只读冒烟——每次运行会
* ① 新建一次预审运行(`/prescreen-workbench` 的「开始 AI 预审」);
* ② 重建一次 DEMO-B 的历史时间线(同 id 递增 revision,不新建对象);
* ③ 留下一条 `status=failed` 的提炼记录(未配置模型时的真实结果,E-28)。
* 与 F28 记录的现象同类:重复运行会累积。提炼那一条是本脚本要证明的「失败就是失败」,
* 不是脏数据被隐藏。
*/
const fs = require ( "node:fs" ) ;
const path = require ( "node:path" ) ;
const puppeteer = require ( "puppeteer-core" ) ;
const CHROME = process . env . CHROME _BIN || "C:/Program Files/Google/Chrome/Application/chrome.exe" ;
const APP = process . env . E2E _APP || "http://localhost:3212" ;
const OUT = process . env . E2E _OUT || path . join ( _ _dirname , ".." , "test-evidence" , "browser-e2e-modules" ) ;
const ROLE = process . env . E2E _ROLE || "manager" ;
const PROJECT = process . env . E2E _PROJECT || "DEMO-B" ;
const results = [ ] ;
function check ( label , ok , detail = "" ) {
results . push ( { label , ok , detail : String ( detail ) } ) ;
console . log ( ` ${ ok ? "PASS" : "FAIL" } ${ label } ${ detail ? " — " + detail : "" } ` ) ;
}
const bodyText = ( page ) => page . evaluate ( ( ) => document . body . innerText ) ;
const noisy = ( list ) => list . filter ( ( text ) => ! /favicon|404 \(Not Found\)/i . test ( text ) ) ;
const wait = ( ms ) => new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
const shot = ( page , name , fullPage = false ) => page . screenshot ( { path : path . join ( OUT , name ) , fullPage } ) ;
/** 点击第一个满足条件的按钮;找不到或禁用则返回 false。 */
const clickButton = ( page , match ) => page . evaluate ( ( needle ) => {
const button = [ ... document . querySelectorAll ( "button" ) ] . find ( ( b ) => b . textContent . includes ( needle ) ) ;
if ( ! button || button . disabled ) return false ;
button . click ( ) ;
return true ;
} , match ) ;
// ------------------------------------------------------------------ AI 预审
async function sectionPrescreen ( page , consoleErrors ) {
console . log ( "\n=== AI 预审(/prescreen-workbench) ===" ) ;
await page . goto ( ` ${ APP } /prescreen-workbench?project= ${ PROJECT } ` , { waitUntil : "networkidle2" } ) ;
await page . waitForFunction (
( ) => document . body . innerText . includes ( "预审与推进沙盘" )
|| document . body . innerText . includes ( "没有可访问的项目" ) ,
{ timeout : 30000 } ) ;
const heading = await page . evaluate ( ( ) => {
const node = document . querySelector ( "main h1" ) ;
return node ? node . textContent . trim ( ) : null ;
} ) ;
check ( "AI 预审标题渲染" , heading === "AI 预审工作台" , ` h1= ${ heading } ` ) ;
// 这一条正是第四批的缺陷 1:读错 `/projects` 字段时项目列表恒为空、整页只剩空态,
// 而空态看着完全合理。所以断言必须落在"下拉里真的有项目"上。
const projects = await page . evaluate ( ( ) => {
const select = document . querySelector ( 'select[aria-label="选择项目"]' ) ;
return select ? [ ... select . options ] . map ( ( item ) => item . value ) : null ;
} ) ;
check ( "项目下拉有可选项(/projects 字段读取正确)" ,
Boolean ( projects && projects . length ) , JSON . stringify ( projects ) ) ;
const plans = await page . evaluate ( ( ) => {
const select = document . querySelector ( 'select[aria-label="评审方案"]' ) ;
return select ? [ ... select . options ] . map ( ( item ) => item . textContent . trim ( ) ) : null ;
} ) ;
check ( "评审方案下拉有可选项" , Boolean ( plans && plans . length ) , JSON . stringify ( plans ) ) ;
const runOptions = ( ) => page . evaluate ( ( ) => {
const select = document . querySelector ( 'select[aria-label="选择预审运行"]' ) ;
return select ? [ ... select . options ] . map ( ( item ) => item . textContent . trim ( ) ) : [ ] ;
} ) ;
const snapshotLine = ( ) => page . evaluate ( ( ) => {
const match = document . body . innerText . match ( /输入快照 (\S+)/ ) ;
return match ? match [ 1 ] : "" ;
} ) ;
const runsBefore = await runOptions ( ) ;
const snapshotBefore = await snapshotLine ( ) ;
// ---- 真实点击「开始 AI 预审」:这是第一批留下的"界面点击未由自动化驱动"缺口
check ( "「开始 AI 预审」按钮可点击" , await clickButton ( page , "开始 AI 预审" ) ) ;
await page . waitForFunction ( ( ) => {
const node = document . querySelector ( '[role="status"]' ) ;
return node && node . textContent . includes ( "预审已进入队列" ) ;
} , { timeout : 20000 } ) . catch ( ( ) => { } ) ;
const notice = await page . evaluate ( ( ) => {
const node = document . querySelector ( '[role="status"]' ) ;
return node ? node . textContent . trim ( ) : "" ;
} ) ;
check ( "点击后给出入队提示" , /预审已进入队列/ . test ( notice ) , ` notice= ${ notice } ` ) ;
// 顺序很重要:先等页面**看见**这次运行在执行(徽标出现),再等它结束。
// 反过来写会立刻成立——setNotice 先于 open() 完成,那一刻 project.runs 里还没有新运行,
// "徽标不存在"于是为真,后面全部断言都落在上一次运行的面板上(第一次跑就是这样误判的)。
await page . waitForFunction ( ( ) => document . body . innerText . includes ( "预审执行中,结果会自动刷新" ) ,
{ timeout : 90000 , polling : 300 } ) . catch ( ( ) => { } ) ;
await page . waitForFunction ( ( ) => ! document . body . innerText . includes ( "预审执行中,结果会自动刷新" )
&& document . body . innerText . includes ( "预审与推进沙盘" ) ,
{ timeout : 240000 , polling : 500 } ) . catch ( ( ) => { } ) ;
check ( "新运行执行结束(页面不再显示执行中)" ,
! ( await bodyText ( page ) ) . includes ( "预审执行中,结果会自动刷新" ) ) ;
// 徽标写着"结果会自动刷新":先不刷新页面,看面板自己有没有跟上新运行。
// 给它一个有上限的宽限:run.status 与预审视图不是同一事务写入的,晚一两拍是正常的;
// 但"永远不刷新"是缺陷——面板原先只在 project.id 变化时 load(),发起的运行根本进不来。
const panelPickedUp = await page . waitForFunction ( ( count ) => {
const select = document . querySelector ( 'select[aria-label="选择预审运行"]' ) ;
return select && select . options . length > count ;
} , { timeout : 12000 , polling : 500 } , runsBefore . length ) . then ( ( ) => true ) . catch ( ( ) => false ) ;
check ( "发起预审后结果面板自动刷新(无需手动刷新页面)" , panelPickedUp ,
` 不刷新页面时运行下拉 ${ runsBefore . length } → ${ ( await runOptions ( ) ) . length } ` ) ;
// 刷新页面:证明新运行确实在服务端,缺的只是面板的刷新时机
await page . reload ( { waitUntil : "networkidle2" } ) ;
// 面板外壳是静态 JSX,标题一出现就断言会量到"还没取数的空壳"(第一次跑就栽在这里)
await page . waitForFunction ( ( count ) => {
const select = document . querySelector ( 'select[aria-label="选择预审运行"]' ) ;
return select && select . options . length > count ;
} , { timeout : 30000 } , runsBefore . length ) . catch ( ( ) => { } ) ;
await wait ( 500 ) ;
const afterRun = await bodyText ( page ) ;
const runsAfter = await runOptions ( ) ;
const snapshotAfter = await snapshotLine ( ) ;
check ( "刷新页面后能读到本次发起的预审记录" ,
runsAfter . length > runsBefore . length || ( snapshotAfter && snapshotAfter !== snapshotBefore ) ,
` 运行下拉 ${ runsBefore . length } → ${ runsAfter . length } ;输入快照 ${ snapshotBefore } → ${ snapshotAfter } ` ) ;
const statusLine = ( afterRun . match ( /运行 (已完成|部分完成|未完成|已取消|排队中|执行中)/ ) || [ ] ) [ 1 ] || "" ;
check ( "运行状态为终态" , Boolean ( statusLine ) && ! [ "排队中" , "执行中" ] . includes ( statusLine ) , statusLine ) ;
// ---- 模型不可用时如实标未完成(BR-47)。配了模型时这条降级记录本就不该出现。
const degradation = await page . evaluate ( ( ) => {
const node = [ ... document . querySelectorAll ( "p" ) ]
. find ( ( p ) => p . textContent . includes ( "未完成项:" ) ) ;
return node ? node . textContent . trim ( ) : "" ;
} ) ;
if ( degradation ) {
check ( "模型不可用时如实显示未完成项与错误码" ,
/( [A-Z0-9_-]+) / . test ( degradation ) , degradation . slice ( 0 , 140 ) ) ;
} else {
check ( "本次运行无降级记录(已配置模型,非失败路径)" , true ) ;
}
// ---- 概率口径:数字要么不出现,要么必须紧邻"演示值/人工设定"
const probability = await page . evaluate ( ( ) => {
const node = [ ... document . querySelectorAll ( "div.xb-finding" ) ]
. find ( ( d ) => d . textContent . includes ( "审批通过概率" ) ) ;
return node ? node . innerText : "" ;
} ) ;
const hasPercent = /\d+%/ . test ( probability ) ;
const labelled = /演示值|人工设定/ . test ( probability ) ;
// 概率区必须真的渲染出来:空串会让"没有裸数字"变成一句空断言
check ( "概率数字必带演示值/人工设定说明(AC-12)" ,
probability . length > 0 && ( ! hasPercent || labelled ) ,
probability . split ( "\n" ) . slice ( 0 , 2 ) . join ( " / " ) . slice ( 0 , 140 ) ) ;
check ( "声明证据阶段不等于审批概率" , /证据阶段 ≠ 审批概率/ . test ( afterRun ) ) ;
// ---- 依据抽屉:真实打开、真实关闭、焦点回位
const opened = await page . evaluate ( ( ) => {
const button = [ ... document . querySelectorAll ( "button" ) ]
. find ( ( b ) => / ^ 查看 . + 的依据$ / . test ( b . getAttribute ( "aria-label" ) || "" ) ) ;
if ( ! button ) return null ;
button . dataset . e2eOpener = "1" ;
button . click ( ) ;
return button . getAttribute ( "aria-label" ) ;
} ) ;
check ( "存在「查看依据」按钮" , Boolean ( opened ) , opened || "未找到" ) ;
await page . waitForFunction ( ( ) => Boolean ( document . querySelector ( '[role="dialog"]' ) ) , { timeout : 10000 } )
. catch ( ( ) => { } ) ;
const dialog = await page . evaluate ( ( ) => {
const node = document . querySelector ( '[role="dialog"]' ) ;
return node ? { label : node . getAttribute ( "aria-label" ) , text : node . innerText } : null ;
} ) ;
check ( "依据抽屉打开并给出依据与版本" ,
Boolean ( dialog ) && /依据与版本/ . test ( dialog . text ) , dialog ? dialog . label : "未打开" ) ;
await shot ( page , "03-prescreen-basis-drawer.png" ) ;
await clickButton ( page , "关闭依据" ) ;
await wait ( 300 ) ;
check ( "关闭依据后抽屉消失" ,
await page . evaluate ( ( ) => ! document . querySelector ( '[role="dialog"]' ) ) ) ;
check ( "关闭依据后焦点回到触发按钮" ,
await page . evaluate ( ( ) => document . activeElement && document . activeElement . dataset . e2eOpener === "1" ) ) ;
// ---- 推进沙盘:有假设动作就点一个真的重算;没有就断言它是"诚实的空态"
const assumptions = await page . evaluate ( ( ) => {
const heading = [ ... document . querySelectorAll ( "h3" ) ] . find ( ( h ) => h . textContent . includes ( "推进沙盘" ) ) ;
if ( ! heading ) return null ;
return [ ... heading . parentElement . querySelectorAll ( 'input[type="checkbox"]' ) ]
. map ( ( box ) => ( box . closest ( "label" ) ? box . closest ( "label" ) . innerText . trim ( ) . slice ( 0 , 40 ) : "" ) ) ;
} ) ;
if ( assumptions === null ) {
check ( "存在推进沙盘区" , false , "未找到推进沙盘标题" ) ;
} else if ( ! assumptions . length ) {
// 假设动作只指向本次方案真实存在的维度(服务端 available_assumptions),
// 演示方案的维度里没有 DC-CASH/DC-TITLE,因此这里给出空态而不是必然失败的选项。
check ( "推进沙盘无可用动作时给出诚实空态" ,
/当前方案没有可推演的假设动作/ . test ( afterRun ) ,
"演示方案的维度不含假设动作目标维度" ) ;
} else {
const box = await page . evaluate ( ( ) => {
const heading = [ ... document . querySelectorAll ( "h3" ) ] . find ( ( h ) => h . textContent . includes ( "推进沙盘" ) ) ;
const input = heading . parentElement . querySelector ( 'input[type="checkbox"]' ) ;
input . click ( ) ;
return true ;
} ) ;
check ( "推进沙盘假设动作可勾选" , box , JSON . stringify ( assumptions ) ) ;
await page . waitForFunction (
( ) => / 所选情景 | 情景未完成 | 该情景未完成 / . test ( document . body . innerText ) ,
{ timeout : 20000 } ) . catch ( ( ) => { } ) ;
const scenario = await bodyText ( page ) ;
check ( "勾选后给出情景结果或如实标未完成" ,
/所选情景|情景未完成|该情景未完成/ . test ( scenario ) ) ;
check ( "情景不改写真实预审(仍显示基线待核实项)" , /真实预审保持不变/ . test ( scenario ) ) ;
await clickButton ( page , "返回真实预审" ) ;
await wait ( 500 ) ;
check ( "可返回真实预审(恢复基线)" ,
! /真实预审保持不变/ . test ( await bodyText ( page ) ) ) ;
}
await shot ( page , "04-prescreen-workbench.png" , true ) ;
}
// ------------------------------------------------------------------ 历史经验加工
async function sectionExperience ( page , consoleErrors ) {
console . log ( "\n=== 历史经验加工(/experience-center) ===" ) ;
consoleErrors . length = 0 ;
await page . goto ( ` ${ APP } /experience-center?project= ${ PROJECT } ` , { waitUntil : "networkidle2" } ) ;
await page . waitForFunction (
( ) => document . body . innerText . includes ( "① 来源" )
|| document . body . innerText . includes ( "没有可访问的项目" ) ,
{ timeout : 30000 } ) ;
const heading = await page . evaluate ( ( ) => {
const node = document . querySelector ( "main h1" ) ;
return node ? node . textContent . trim ( ) : null ;
} ) ;
check ( "历史经验加工标题渲染" , heading === "历史经验加工中心" , ` h1= ${ heading } ` ) ;
const loaded = await bodyText ( page ) ;
for ( const label of [ "① 来源" , "② 提炼" , "③ 候选" , "④ 方案" ] ) {
check ( ` 四段流程含「 ${ label } 」 ` , loaded . includes ( label ) ) ;
}
const projects = await page . evaluate ( ( ) => {
const select = document . querySelector ( 'select[aria-label="选择项目"]' ) ;
return select ? [ ... select . options ] . map ( ( item ) => item . value ) : null ;
} ) ;
check ( "项目下拉有可选项(/projects 字段读取正确)" ,
Boolean ( projects && projects . length ) , JSON . stringify ( projects ) ) ;
// ---- ① 时间线:真实点击重建
const cardRows = ( ) => page . evaluate ( ( ) =>
[ ... document . querySelectorAll ( "button" ) ] . filter ( ( b ) => b . textContent . includes ( "自然语言修改适用范围" ) ) . length ) ;
check ( "「从项目材料重建时间线」按钮可点击" , await clickButton ( page , "从项目材料重建时间线" ) ) ;
await page . waitForFunction (
( ) => / 已恢复 · \ d + 个事件 | 过程材料不完整 · \ d + 个事件 / . test ( document . body . innerText ) ,
{ timeout : 30000 } ) . catch ( ( ) => { } ) ;
const timelineText = await bodyText ( page ) ;
const timelineBadge = ( timelineText . match ( /(已恢复|过程材料不完整) · (\d+) 个事件/ ) || [ ] ) ;
check ( "点击后时间线给出状态与事件数" , Boolean ( timelineBadge [ 0 ] ) , timelineBadge [ 0 ] || "未出现徽标" ) ;
check ( "时间线不编造经过(无结构化记录时明确标注不完整)" ,
! /过程材料不完整/ . test ( timelineText ) || /未发现结构化的问询\/决定记录/ . test ( timelineText ) ) ;
// act() 期间 busy=true 会让按钮保持禁用;先等这次重建真正结束,否则量到的是中间态
await page . waitForFunction ( ( ) => {
const button = [ ... document . querySelectorAll ( "button" ) ] . find ( ( b ) => b . textContent . includes ( "请求提炼" ) ) ;
return button && ! button . disabled ;
} , { timeout : 15000 } ) . catch ( ( ) => { } ) ;
const extractEnabled = await page . evaluate ( ( ) => {
const button = [ ... document . querySelectorAll ( "button" ) ] . find ( ( b ) => b . textContent . includes ( "请求提炼" ) ) ;
return button ? ! button . disabled : null ;
} ) ;
check ( "时间线就绪后「请求提炼」可用" , extractEnabled === true ) ;
// ---- ② 真实提炼:模型没配就必须是"未完成",且一张候选都不许产生(BR-48)
const before = await cardRows ( ) ;
// 先走一次"没有来源事件、也没有提炼要求":服务端按契约拒绝,界面必须如实显示这条理由,
// 而不是安静地什么都不发生或者伪造一条草稿。
await clickButton ( page , "请求提炼" ) ;
await page . waitForFunction ( ( ) => Boolean ( document . querySelector ( '[role="alert"]' ) ) , { timeout : 15000 } )
. catch ( ( ) => { } ) ;
const rejection = await page . evaluate ( ( ) => {
const node = document . querySelector ( '[role="alert"]' ) ;
return node ? node . textContent . trim ( ) : "" ;
} ) ;
check ( "没有来源事件也没有提炼要求时如实拒绝" ,
/至少给出一个来源事件或一条提炼要求/ . test ( rejection ) , rejection . slice ( 0 , 120 ) ) ;
// 再真的发一次能通过的提炼。演示项目的时间线是 0 个事件(材料里没有结构化的问询/决定记录),
// 所以这里用"提炼要求"满足服务端的前置条件——顺带说明来源事件勾选在本数据集下点不到。
await page . type ( 'input[placeholder*="重点整理"]' , "重点整理回款依赖的核查方法" ) ;
check ( "「请求提炼」按钮可点击" , await clickButton ( page , "请求提炼" ) ) ;
await page . waitForFunction (
( ) => / 提炼未完成 | 提炼完成 / . test ( document . body . innerText ) , { timeout : 90000 } ) . catch ( ( ) => { } ) ;
const afterExtract = await bodyText ( page ) ;
check ( "提炼记录被渲染出来" , /提炼未完成|提炼完成/ . test ( afterExtract ) ) ;
if ( /提炼未完成/ . test ( afterExtract ) ) {
check ( "模型不可用时显示 E-28 文案与错误码" ,
/本次未能完成经验提炼/ . test ( afterExtract ) && /EXTRACTION_UNAVAILABLE/ . test ( afterExtract ) ) ;
}
const after = await cardRows ( ) ;
check ( "提炼未完成时不产生候选草稿(BR-48)" , after === before , ` 候选行 ${ before } → ${ after } ` ) ;
// ---- ③ 候选卡渲染:来源与状态可区分,且来源不是靠颜色单独传意(AC-42)
const cards = await page . evaluate ( ( ) => {
const badges = [ ... document . querySelectorAll ( ".xb-origin" ) ]
. map ( ( node ) => ( { text : node . textContent . trim ( ) , icon : Boolean ( node . querySelector ( "svg" ) ) } ) ) ;
return { count : badges . length , sample : badges . slice ( 0 , 3 ) } ;
} ) ;
check ( "候选卡渲染来源徽标(文字+图标)" ,
cards . count > 0 && cards . sample . every ( ( b ) => b . text && b . icon ) , JSON . stringify ( cards . sample ) ) ;
check ( "候选卡渲染状态与版本徽标" ,
/(草稿|已发布)/ . test ( afterExtract ) && /v\d+\.\d+\.\d+ · revision \d+/ . test ( afterExtract ) ) ;
// ---- 修订面板:修的是草稿,且不能一键发布
// act() 是"先显示提示、再刷新、最后解除 busy":提示出现时按钮仍全部禁用(实测 76 个),
// 此时点击会被 disabled 吞掉。等它恢复再点,否则量到的是"点了没反应"的假象。
await page . waitForFunction ( ( ) => {
const button = [ ... document . querySelectorAll ( "button" ) ]
. find ( ( b ) => b . textContent . includes ( "自然语言修改适用范围" ) ) ;
return button && ! button . disabled ;
} , { timeout : 15000 } ) . catch ( ( ) => { } ) ;
const toggled = await page . evaluate ( ( ) => {
const button = [ ... document . querySelectorAll ( "button" ) ]
. find ( ( b ) => b . textContent . includes ( "自然语言修改适用范围" ) ) ;
if ( ! button ) return "未找到按钮" ;
if ( button . disabled ) return "按钮仍禁用(busy 未解除)" ;
button . click ( ) ;
return "clicked" ;
} ) ;
check ( "能点开候选行的修订入口" , toggled === "clicked" , toggled ) ;
await wait ( 400 ) ;
const panels = await page . evaluate ( ( ) =>
[ ... document . querySelectorAll ( "button" ) ] . filter ( ( b ) => b . textContent . includes ( "形成草稿修订" ) ) . length ) ;
// 同一个 card.id 在列表里有多行(草稿行与各版本行共享 id):
// 面板若按 card.id 记录"展开哪张卡",点一行会连开多行——真实浏览器里量到过 4 个。
check ( "展开修订面板只影响被点的那一行" , panels === 1 , ` 展开的修订面板数= ${ panels } ` ) ;
const saveDisabled = await page . evaluate ( ( ) => {
const button = [ ... document . querySelectorAll ( "button" ) ] . find ( ( b ) => b . textContent . includes ( "形成草稿修订" ) ) ;
return button ? button . disabled : null ;
} ) ;
check ( "未填写修订要求时「形成草稿修订」保持禁用" , saveDisabled === true ) ;
check ( "修订区声明草稿不直接生效" , /不会自动发布/ . test ( afterExtract ) || /不直接生效/ . test ( afterExtract ) ) ;
// ---- 身份门禁:manager 不该看到发布按钮
const managerPublish = await page . evaluate ( ( ) =>
[ ... document . querySelectorAll ( "button" ) ] . filter ( ( b ) => b . textContent . includes ( "审核发布" ) ) . length ) ;
check ( "manager 身份看不到「审核发布」按钮" , managerPublish === 0 , ` 数量= ${ managerPublish } ` ) ;
await shot ( page , "05-experience-center.png" , true ) ;
// ---- 换 admin 身份:评审入口应出现,但发布仍需要 reviewer( BR-42 / AC-43)
// 没有用 reviewer 做这一步:演示身份里 reviewer 的 `/projects` 只授权到 DEMO-C,
// 而 DEMO-C 没有知识候选,浏览器里点不到「审核发布」——这是演示数据的边界,不是门禁本身。
consoleErrors . length = 0 ;
await page . evaluate ( ( ) => sessionStorage . setItem ( "xingbang-demo-role" , "admin" ) ) ;
await page . goto ( ` ${ APP } /experience-center?project= ${ PROJECT } ` , { waitUntil : "networkidle2" } ) ;
await page . waitForFunction ( ( ) => document . body . innerText . includes ( "③ 候选" ) , { timeout : 40000 } ) ;
await wait ( 600 ) ;
const adminEvaluate = await page . evaluate ( ( ) =>
[ ... document . querySelectorAll ( "button" ) ] . filter ( ( b ) => b . textContent . includes ( "隔离评测" ) ) . length ) ;
const adminPublish = await page . evaluate ( ( ) =>
[ ... document . querySelectorAll ( "button" ) ] . filter ( ( b ) => b . textContent . includes ( "审核发布" ) ) . length ) ;
check ( "admin 身份出现「隔离评测」入口" , adminEvaluate > 0 , ` 数量= ${ adminEvaluate } ` ) ;
check ( "admin 身份仍不出现「审核发布」(发布限 reviewer) " , adminPublish === 0 , ` 数量= ${ adminPublish } ` ) ;
await shot ( page , "06-experience-admin.png" , true ) ;
await page . evaluate ( ( role ) => sessionStorage . setItem ( "xingbang-demo-role" , role ) , ROLE ) ;
// ---- 浏览器控制台:第四批的缺陷 1–4 都是"只有浏览器能看见"的那一类
const errors = noisy ( consoleErrors ) ;
check ( "历史经验加工页控制台无脚本错误" , errors . length === 0 ,
errors . slice ( 0 , 3 ) . map ( ( t ) => t . slice ( 0 , 120 ) ) . join ( " | " ) ) ;
return errors ;
}
( async ( ) => {
fs . mkdirSync ( OUT , { recursive : true } ) ;
const browser = await puppeteer . launch ( {
executablePath : CHROME ,
headless : "new" ,
args : [ "--no-proxy-server" , "--window-size=1440,1200" ] ,
} ) ;
const page = await browser . newPage ( ) ;
await page . setViewport ( { width : 1440 , height : 1200 } ) ;
const consoleErrors = [ ] ;
page . on ( "console" , ( message ) => {
if ( message . type ( ) === "error" ) consoleErrors . push ( message . text ( ) ) ;
} ) ;
page . on ( "pageerror" , ( error ) => consoleErrors . push ( "pageerror: " + error . message ) ) ;
// 应用从 sessionStorage 读身份:先落到同源页面再写,否则进目标页时身份还没生效
await page . goto ( ` ${ APP } /experience-center ` , { waitUntil : "domcontentloaded" } ) ;
await page . evaluate ( ( role ) => sessionStorage . setItem ( "xingbang-demo-role" , role ) , ROLE ) ;
const report = { role : ROLE , project : PROJECT , sections : { } } ;
try {
consoleErrors . length = 0 ;
await sectionPrescreen ( page , consoleErrors ) ;
report . sections . prescreen = noisy ( consoleErrors ) . map ( ( text ) => text . slice ( 0 , 300 ) ) ;
} catch ( error ) {
check ( "AI 预审章节未抛异常" , false , String ( error ) . slice ( 0 , 200 ) ) ;
}
try {
report . sections . experience = await sectionExperience ( page , consoleErrors ) ;
} catch ( error ) {
check ( "历史经验加工章节未抛异常" , false , String ( error ) . slice ( 0 , 200 ) ) ;
}
await browser . close ( ) ;
const failed = results . filter ( ( item ) => ! item . ok ) ;
fs . writeFileSync ( path . join ( OUT , "result.json" ) ,
JSON . stringify ( { ... report , results } , null , 2 ) ) ;
console . log ( ` \n 截图与结果写入 ${ OUT } ` ) ;
console . log ( ` 总计 ${ results . length } 项,失败 ${ failed . length } 项 ` ) ;
process . exit ( failed . length ? 1 : 0 ) ;
} ) ( ) . catch ( ( error ) => {
console . error ( "E2E 运行失败:" , error ) ;
process . exit ( 2 ) ;
} ) ;