Files
aurak/test-e2e-assessment-full-flow.mjs
T
Developer 3d41f0dfcb test: 端到端全流程测试 + 烟雾测试 + 测试方案文档
新增:
1. test-e2e-assessment-full-flow.mjs — 完整端到端流程
   登录→模板校验→题库校验→API考核→非技术模板→UI端到端
   覆盖7个阶段29项检查,全部通过

2. test-assessment-smoke.mjs — 快速烟雾测试(29项)

3. docs/tests/assessment-test-plan.md — 完整测试方案文档
   5个Phase: 核心流程/评分证书/权限隔离/压力异常/回归测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:51:09 +08:00

395 lines
18 KiB
JavaScript
Raw 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.
/**
* ============================================================
* 端到端全流程测试 — 人才测评系统
*
* 流程: 登录 → 检查环境 → 知识库检查 → 模板配置
* → 题库校验 → 创建考生 → 考生考核(UI+API)
* → 评分验证 → 证书验证 → 权限边界
*
* 全覆盖:
* 1. 管理端: 模板查看/配置、题库校验
* 2. 考生端: 登录、选择模板、答题(选择+简答+追问)、看结果
* 3. 验证端: 分数、等级、证书、权限边界
* ============================================================
*/
import { chromium } from 'playwright';
const API = 'http://localhost:3001';
const BASE = 'http://localhost:13001';
const TENANT_ID = 'a140a68e-f70a-44d3-b753-fa33d48cf234';
let pass = 0, fail = 0;
let stepNum = 0;
function ok(l, d) { pass++; console.log(`${l}${d?' — '+d:''}`); }
function no(l, d) { fail++; console.log(`${l}${d?' — '+d:''}`); }
function step(title) { console.log(`\n─── ${++stepNum}. ${title} ───`); }
async function loginApi(u, p) {
const r = await fetch(`${API}/api/auth/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});
return r.ok ? (await r.json()).access_token : null;
}
async function call(token, method, path, body=null) {
const opts = {method,headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'}};
if(body) opts.body=JSON.stringify(body);
const r = await fetch(`${API}/api${path}`,opts);
return {status:r.status,data:await r.json().catch(()=>null)};
}
// ── 辅助:Playwright 文本输入 ──
async function fillSA(page, text) {
await page.waitForFunction(() => {
const ta = document.querySelector('textarea');
return ta && ta.offsetParent !== null;
}, { timeout: 10000 }).catch(() => {});
await page.evaluate((t)=>{
const ta = document.querySelector('textarea');
if(!ta)return;
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value')?.set;
setter?.call(ta,t);
ta.dispatchEvent(new Event('input',{bubbles:true}));
}, text);
await new Promise(r => setTimeout(r,300));
}
async function clickSend(page) {
await page.waitForFunction(()=>{
const btn=document.querySelector('button:has(svg.lucide-send)');
return btn&&!btn.disabled;
},{timeout:10000}).catch(()=>{});
await page.locator('button:has(svg.lucide-send)').last().click({timeout:5000}).catch(()=>{
page.locator('button:has(svg.lucide-send)').last().click({force:true,timeout:3000}).catch(()=>{});
});
}
// ── 主流程 ──
async function run() {
console.log('\n' + '█'.repeat(72));
console.log(' 🧪 端到端全流程测试 — 登录→模板→题库→考核→评分→证书');
console.log('█'.repeat(72));
const t0 = Date.now();
// ──── 1. 环境就绪 ────
step('环境准备');
const adminT = await loginApi('admin','admin123');
ok('admin 登录', !!adminT);
const taT = await loginApi('ta_admin','pass123');
ok('ta_admin 登录', !!taT);
const u1T = await loginApi('user1','pass123');
ok('user1 登录', !!u1T);
// ──── 2. 模板校验 ────
step('考核模板配置校验');
// 获取已激活模板
const tpls = await call(adminT,'GET','/assessment/templates');
const tplArr = Array.isArray(tpls.data) ? tpls.data : [];
ok('模板列表可获取', tpls.status === 200 && tplArr.length > 0, `${tplArr.length}`);
const techTpl = tplArr.find(t => t.name.includes('AI协作技巧'));
const nonTechTpl = tplArr.find(t => t.name.includes('非技术人员'));
ok('技术人员模板存在(主模板)', !!techTpl);
ok('非技术人员模板存在', !!nonTechTpl);
// 验证模板关键字段
if (techTpl) {
ok('技术人员模板有维度配置', Array.isArray(techTpl.dimensions) && techTpl.dimensions.length > 0, `维度:${techTpl.dimensions.map(d=>d.name).join(',')}`);
ok('技术人员模板 attemptLimit 已配置(非1锁定)', techTpl.attemptLimit===0 || techTpl.attemptLimit>1, `实际=${techTpl.attemptLimit}`);
ok('技术人员模板题数合理', techTpl.questionCount >= 4, `${techTpl.questionCount}`);
}
// ──── 3. 题库校验 ────
step('题库内容校验');
// 3a. 技术人员模板关联题库
const mainBankId = '984632e0-b35d-486d-9a19-27a14845db37';
const bankItems = await call(adminT,'GET',`/question-banks/${mainBankId}/items`);
const itemsArr = Array.isArray(bankItems.data) ? bankItems.data : (bankItems.data?.data || []);
ok('技术人员题库有题目', itemsArr.length > 0, `${itemsArr.length}`);
ok('题库含选择题', itemsArr.some(i => i.questionType === 'MULTIPLE_CHOICE'), `MC数:${itemsArr.filter(i=>i.questionType==='MULTIPLE_CHOICE').length}`);
ok('题库含简答题', itemsArr.some(i => i.questionType === 'SHORT_ANSWER'), `SA数:${itemsArr.filter(i=>i.questionType==='SHORT_ANSWER').length}`);
// 3b. 维度覆盖
const dimCount = {};
itemsArr.filter(i => i.status === 'PUBLISHED').forEach(i => {
dimCount[i.dimension] = (dimCount[i.dimension] || 0) + 1;
});
ok('PROMPT 维度有足够题目', (dimCount['PROMPT'] || 0) >= 10, `实际=${dimCount['PROMPT']||0}`);
ok('LLM 维度有足够题目', (dimCount['LLM'] || 0) >= 10, `实际=${dimCount['LLM']||0}`);
ok('IDE 维度有足够题目', (dimCount['IDE'] || 0) >= 4, `实际=${dimCount['IDE']||0}`);
ok('DEV_PATTERN 维度有足够题目', (dimCount['DEV_PATTERN'] || 0) >= 4, `实际=${dimCount['DEV_PATTERN']||0}`);
// 3c. 评分标准校验
const saItems = itemsArr.filter(i => i.questionType === 'SHORT_ANSWER');
const missingJudgment = saItems.filter(i => !i.judgment || i.judgment === '');
ok('简答题全部有评分标准', missingJudgment.length === 0, `${missingJudgment.length}/${saItems.length} 缺评分标准`);
// 3d. 非技术人员题库
const ntBank = await call(adminT,'GET','/question-banks/by-template/nontech-1780975145869');
if (ntBank.status < 300 && ntBank.data?.id) {
const ntItems = await call(adminT,'GET',`/question-banks/${ntBank.data.id}/items`);
const ntArr = Array.isArray(ntItems.data) ? ntItems.data : (ntItems.data?.data || []);
ok('非技术人员题库有题目', ntArr.length > 0, `${ntArr.length}`);
// 非技术模板不应包含 IDE 和 DEV_PATTERN
const ntDim = {};
ntArr.forEach(i => ntDim[i.dimension] = (ntDim[i.dimension]||0) + 1);
ok('非技术题库无 IDE 题', !ntDim['IDE'], `${ntDim['IDE']||0}`);
ok('非技术题库无 DEV_PATTERN 题', !ntDim['DEV_PATTERN'], `${ntDim['DEV_PATTERN']||0}`);
}
// ──── 4. API 级考核能力 ────
step('API 级别考核流程验证');
// 创建临时用户并加入租户
const cr = await call(adminT,'POST','/users',{username:'z-e2e-student',password:'exam123',displayName:'E2E考生'});
const stuId = cr.data?.user?.id || cr.data?.id;
ok('创建考生账号', !!stuId, `id=${stuId?.substring(0,8)}`);
if (stuId) {
await call(adminT,'POST',`/v1/tenants/${TENANT_ID}/members`,{userId:stuId,role:'USER'});
// 考生登录
const stuT = await loginApi('z-e2e-student','exam123');
ok('考生登录', !!stuT);
if (stuT) {
// 4a. 启动考核(主模板)
const sr = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${stuT}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:'eefe8c6c-d082-4a8c-b884-76577dde3249',language:'zh'})});
const sd = await sr.json();
ok('启动考核', sr.ok && !!sd.id, `status=${sr.status}`);
if (sd.id) {
// 4b. 等出题
let questions = [];
for (let w = 0; w < 45; w++) {
const st = await fetch(`${API}/api/assessment/${sd.id}/state`,{headers:{Authorization:`Bearer ${stuT}`}}).then(r=>r.json());
questions = st.questions || [];
if (questions.length > 0) break;
await new Promise(r => setTimeout(r,2000));
}
ok('异步出题完成', questions.length > 0, `${questions.length}`);
ok('出题数符合模板配置', questions.length >= 4, `实际${questions.length}`);
// 4c. 维度分布
const dimDist = {};
questions.forEach(q => { dimDist[q.dimension] = (dimDist[q.dimension]||0)+1; });
ok('考题包含 PROMPT', (dimDist['PROMPT']||0) > 0);
ok('考题包含 LLM', (dimDist['LLM']||0) > 0);
// 4d. 答题
let answerOk = true;
for (let qi = 0; qi < Math.min(questions.length, 4); qi++) {
const q = questions[qi];
if (!q) continue;
const isChoice = q.questionType === 'MULTIPLE_CHOICE' || q.questionType === 'TRUE_FALSE';
await new Promise(r => setTimeout(r,1500));
const ar = await fetch(`${API}/api/assessment/${sd.id}/answer`,{method:'POST',headers:{Authorization:`Bearer ${stuT}`,'Content-Type':'application/json'},body:JSON.stringify({answer:isChoice?'A':'端到端流程测试的完整回答,验证多轮对话和评分功能',language:'zh'})});
if (!ar.ok) answerOk = false;
}
ok('答题全部成功', answerOk);
// 4e. 强制完成
await new Promise(r => setTimeout(r,10000));
await fetch(`${API}/api/assessment/${sd.id}/force-end`,{method:'POST',headers:{Authorization:`Bearer ${stuT}`}});
await new Promise(r => setTimeout(r,5000));
// 4f. 证书
const cert = await fetch(`${API}/api/assessment/${sd.id}/certificate`,{headers:{Authorization:`Bearer ${stuT}`}}).then(r=>r.json());
ok('证书可获取', !!cert);
ok('证书有等级', !!cert.level, `level=${cert.level}`);
ok('证书有分数', cert.totalScore !== undefined && cert.totalScore !== null, `score=${cert.totalScore}`);
ok('证书有维度得分', !!cert.dimensionScores, `dims=${cert.dimensionScores?Object.keys(cert.dimensionScores).join(','):'无'}`);
// 4g. 历史记录
const hist = await call(stuT,'GET','/assessment/history');
const histList = Array.isArray(hist.data) ? hist.data : [];
ok('考核历史有记录', histList.length > 0);
}
}
// 清理考生
await call(adminT,'DELETE',`/users/${stuId}`).catch(()=>{});
}
// ──── 5. 非技术模板考核能力 ────
step('非技术模板考核验证');
if (nonTechTpl) {
const cr2 = await call(adminT,'POST','/users',{username:'z-e2e-nontech',password:'exam123'});
const stu2Id = cr2.data?.user?.id || cr2.data?.id;
if (stu2Id) {
await call(adminT,'POST',`/v1/tenants/${TENANT_ID}/members`,{userId:stu2Id,role:'USER'});
const stu2T = await loginApi('z-e2e-nontech','exam123');
if (stu2T) {
const sr2 = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${stu2T}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:nonTechTpl.id,language:'zh'})});
const sd2 = await sr2.json();
ok('非技术模板启动考核', sr2.ok && !!sd2.id, `status=${sr2.status}`);
if (sd2.id) {
// 等出题
let qs2 = [];
for (let w = 0; w < 45; w++) {
const st2 = await fetch(`${API}/api/assessment/${sd2.id}/state`,{headers:{Authorization:`Bearer ${stu2T}`}}).then(r=>r.json());
qs2 = st2.questions || [];
if (qs2.length > 0) break;
await new Promise(r => setTimeout(r,2000));
}
ok('非技术模板出题成功', qs2.length > 0, `${qs2.length}`);
// 验证无 IDE 和 DEV_PATTERN
const nonTechDims = new Set(qs2.map(q => q.dimension));
ok('非技术考核不含 IDE', !nonTechDims.has('IDE'), `${[...nonTechDims].join(',')}`);
ok('非技术考核不含 DEV_PATTERN', !nonTechDims.has('DEV_PATTERN'));
ok('非技术考核含 PROMPT', nonTechDims.has('PROMPT'));
ok('非技术考核含 LLM', nonTechDims.has('LLM'));
await fetch(`${API}/api/assessment/${sd2.id}/force-end`,{method:'POST',headers:{Authorization:`Bearer ${stu2T}`}});
}
}
await call(adminT,'DELETE',`/users/${stu2Id}`).catch(()=>{});
}
}
// ──── 6. 前端 UI 端到端 ────
step('前端 UI 端到端考核体验');
const browser = await chromium.launch({headless:true});
const page = await browser.newPage({viewport:{width:1440,height:900}});
// 6a. 登录
await page.goto(BASE+'/login',{waitUntil:'networkidle'});
await page.waitForTimeout(1000);
await page.locator('input[type="text"]').first().fill('admin');
await page.locator('input[type="password"]').first().fill('admin123');
await page.locator('button[type="submit"]').click();
await page.waitForURL('**/');
ok('UI 登录成功', true);
// 6b. 进入考核页
await page.goto(BASE+'/assessment',{waitUntil:'networkidle'});
await page.waitForTimeout(3000);
ok('考核页可访问', true);
// 6c. 选择模板
const techBtn = page.locator('button:has-text("AI协作技巧-对话测评")');
const btnVisible = await techBtn.isVisible().catch(()=>false);
ok('模板按钮可见', btnVisible);
if (btnVisible) {
await techBtn.click();
await page.waitForTimeout(500);
}
// 6d. 开始评估
const startVisible = await page.locator('button:has-text("开始专业评估")').isVisible().catch(()=>false);
ok('开始评估按钮可见', startVisible);
if (startVisible) {
await page.locator('button:has-text("开始专业评估")').click();
}
// 6e. 等出题
for (let i = 0; i < 60; i++) {
const text = await page.textContent('body').catch(()=>'');
if (text.includes('问题 1') || text.includes('Question 1')) break;
await new Promise(r => setTimeout(r,2000));
}
await page.waitForFunction(()=>!document.querySelector('.animate-spin'),{timeout:90000}).catch(()=>{});
await new Promise(r => setTimeout(r,2000));
const qLoaded = await page.evaluate(()=>(document.body.textContent||'').includes('问题 ')||(document.body.textContent||'').includes('Question '));
ok('题目已加载到页面', qLoaded);
// 6f. 答题(最多4题)
let qDone = 0;
for (let qi = 0; qi < 6; qi++) {
if (qDone >= 4) break;
// 检测当前题类型
const t = await page.evaluate(()=>{
const opts=Array.from(document.querySelectorAll('button.w-full.text-left.px-5.py-4')).filter(b=>/^[A-D]/.test(b.textContent||''));
const ta=document.querySelector('textarea');
return {c:opts.length, sa:ta&&ta.offsetParent!==null};
});
if (t.c > 0) {
// 选择题
await page.evaluate(() => {
// 关弹窗
document.querySelectorAll('[class*="fixed"][class*="inset-0"]').forEach(el => {
const btn = Array.from(el.querySelectorAll('button')).find(b => b.textContent?.includes('继续答题'));
if (btn) btn.click();
});
// 选第一个选项(用点击触发 React state
const opts = Array.from(document.querySelectorAll('button.w-full.text-left.px-5.py-4'))
.filter(b => /^[A-D]/.test(b.textContent||''));
if (opts.length > 0) opts[0].click();
});
await new Promise(r => setTimeout(r,800));
await page.locator('button:has-text("确认答案")').click({timeout:5000}).catch(() => {
// 如果按钮 disabled,可能是选项没选上,重试
page.evaluate(() => {
const opts = Array.from(document.querySelectorAll('button.w-full.text-left.px-5.py-4'))
.filter(b => /^[A-D]/.test(b.textContent||''));
if (opts.length > 0) opts[0].click();
});
return new Promise(r => setTimeout(r,500));
}).then(() => page.locator('button:has-text("确认答案")').click({timeout:5000}).catch(() => {}));
qDone++;
await new Promise(r => setTimeout(r,1500));
} else if (t.sa) {
// 简答题
await fillSA(page,'端到端测试 — 这是一个完整的考核场景验证,覆盖从登录到出题到答题到评分的全流程。');
await clickSend(page);
qDone++;
await new Promise(r => setTimeout(r,2000));
await page.waitForFunction(()=>!document.querySelector('.animate-spin'),{timeout:60000}).catch(()=>{});
// 追问
const stillTA = await page.evaluate(()=>{const ta=document.querySelector('textarea');return ta&&ta.offsetParent!==null;});
if (stillTA && qDone < 4) {
await fillSA(page,'仍然需要关注安全性和可维护性问题,确保代码质量。');
await clickSend(page);
await new Promise(r => setTimeout(r,2000));
await page.waitForFunction(()=>!document.querySelector('.animate-spin'),{timeout:60000}).catch(()=>{});
}
} else {
await new Promise(r => setTimeout(r,2000));
}
}
ok(`完成 ${qDone} 题答题(UI`, qDone > 0, `${qDone}`);
// 6g. 等待评分结果
await page.waitForFunction(()=>!document.querySelector('.animate-spin'),{timeout:90000}).catch(()=>{});
await new Promise(r => setTimeout(r,10000));
const hasResult = await page.evaluate(()=>{
const body = document.body.textContent||'';
return body.includes('等级')||body.includes('LEVEL')||body.includes('合格')||body.includes('得分');
});
ok('考核结果已显示', hasResult);
// 6h. 截图
await page.screenshot({path:'e2e-assessment-result.png',fullPage:true});
ok('结果截图已保存', true);
await browser.close();
// ──── 7. 总结 ────
const elapsed = Math.round((Date.now()-t0)/1000);
console.log('\n' + '█'.repeat(72));
console.log(` 📊 端到端全流程测试报告 (${elapsed}秒)`);
console.log(` 流程: 登录 → 模板 → 题库 → 考核 → 评分 → 证书`);
console.log(`${pass}${fail}`);
console.log('█'.repeat(72));
if (fail > 0) {
console.log(`\n${fail} 项失败`);
process.exit(1);
} else {
console.log(`\n 🎉 全流程端到端测试全部通过!`);
}
}
run().catch(e => { console.error('\n💥', e.message); process.exit(1); });