/** * ============================================================ * 端到端全流程测试 — 人才测评系统 * * 流程: 登录 → 检查环境 → 知识库检查 → 模板配置 * → 题库校验 → 创建考生 → 考生考核(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); });