c57c3028e2
- 修复 shuffleArray 返回新数组但调用处用 const 未接收返回值(3处) - 新增 test-multiround.mjs Playwright 多轮对话测试(简答+追问全流程) - 新增 do-assessment.mjs / check-result.mjs 考核体验脚本 - CLAUDE.md 增加 AI 工作流指令规则 - package.json 添加 playwright 依赖 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
268 lines
12 KiB
JavaScript
268 lines
12 KiB
JavaScript
import { chromium } from 'playwright';
|
||
|
||
const BASE = 'http://localhost:13001';
|
||
|
||
async function waitForSpinner(page) {
|
||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
}
|
||
|
||
/** Extract the last assistant message (question text) */
|
||
async function getLastQuestion(page) {
|
||
return await page.evaluate(() => {
|
||
// Find all message bubbles: elements with px-5 py-4 classes
|
||
const allBubbles = Array.from(document.querySelectorAll('.px-5.py-4'));
|
||
// The last one that's from assistant (white bg, not indigo) is the question
|
||
for (let i = allBubbles.length - 1; i >= 0; i--) {
|
||
const el = allBubbles[i];
|
||
const text = el.textContent || '';
|
||
const style = el.getAttribute('class') || '';
|
||
// Skip user messages (indigo bg) and empty/footer text
|
||
if (style.includes('bg-indigo')) continue;
|
||
if (text.length < 20) continue;
|
||
return text.substring(0, 600);
|
||
}
|
||
return '';
|
||
});
|
||
}
|
||
|
||
async function run() {
|
||
console.log('=== 🧑🎓 我来做题! ===\n');
|
||
const browser = await chromium.launch({ headless: true });
|
||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||
|
||
// 登录
|
||
console.log('[1] 登录...');
|
||
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('**/');
|
||
console.log(' ✅ 登录成功');
|
||
|
||
// 进入考核
|
||
await page.goto(`${BASE}/assessment`, { waitUntil: 'networkidle' });
|
||
await page.waitForTimeout(2000);
|
||
await page.locator('button:has-text("AI协作技巧")').first().click();
|
||
await page.waitForTimeout(500);
|
||
await page.locator('button:has-text("开始专业评估")').first().click();
|
||
|
||
// 等出题
|
||
console.log('\n[2] 等待出题...');
|
||
for (let i = 0; i < 120; i++) {
|
||
const text = await page.textContent('body').catch(() => '');
|
||
if (text.includes('问题 ') || text.includes('Question ')) break;
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
}
|
||
await waitForSpinner(page);
|
||
console.log(' ✅ 题目已加载\n');
|
||
|
||
// 答题
|
||
let qIdx = 1;
|
||
const totalQs = 4;
|
||
|
||
while (qIdx <= totalQs) {
|
||
// 判断是选择题还是简答题
|
||
const state = await page.evaluate(() => {
|
||
// 选择题选项按钮:CSS类 w-full text-left px-5 py-4
|
||
const optionBtns = Array.from(document.querySelectorAll('button.w-full.text-left.px-5.py-4'))
|
||
.filter(b => !b.textContent?.includes('确认答案'));
|
||
// textarea 表示简答题
|
||
const ta = document.querySelector('textarea');
|
||
const busy = document.querySelector('.animate-spin') !== null;
|
||
// 确认答案按钮:找 button 文字包含"确认"
|
||
const confirmBtns = Array.from(document.querySelectorAll('button'))
|
||
.filter(b => (b.textContent || '').includes('确认'));
|
||
return {
|
||
optionCount: optionBtns.length,
|
||
optionTexts: optionBtns.map(b => b.textContent?.trim() || ''),
|
||
hasTextarea: ta !== null && ta.offsetParent !== null,
|
||
hasConfirmBtn: confirmBtns.length > 0,
|
||
busy,
|
||
};
|
||
});
|
||
|
||
if (state.busy) {
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
continue;
|
||
}
|
||
|
||
// 读取题目
|
||
const question = await getLastQuestion(page);
|
||
console.log(`\n═══ 第 ${qIdx}/${totalQs} 题 ═══`);
|
||
console.log(`📖 ${question}\n`);
|
||
|
||
if (state.optionCount > 0) {
|
||
// ── 选择题 ──
|
||
console.log('📋 选项:');
|
||
state.optionTexts.forEach((t, i) => {
|
||
console.log(` ${String.fromCharCode(65 + i)}) ${t}`);
|
||
});
|
||
console.log('');
|
||
|
||
// 凭常识选题
|
||
const btns = page.locator('button.w-full.text-left.px-5.py-4');
|
||
const count = await btns.count();
|
||
|
||
// 根据题目内容推理
|
||
const qLower = question.toLowerCase();
|
||
const texts = state.optionTexts.map(t => t.toLowerCase());
|
||
let chosen = 1; // default B
|
||
|
||
if (qLower.includes('提示词') || qLower.includes('prompt') || qLower.includes('prompts')) {
|
||
chosen = texts.findIndex(t => t.includes('清晰') || t.includes('具体') || t.includes('举例') || t.includes('角色'));
|
||
} else if (qLower.includes('安全') || qLower.includes('敏感') || qLower.includes('泄露')) {
|
||
chosen = texts.findIndex(t => t.includes('脱敏') || t.includes('敏感') || t.includes('安全'));
|
||
} else if (qLower.includes('测试') || qLower.includes('测试用例')) {
|
||
chosen = texts.findIndex(t => t.includes('测试') || t.includes('质量') || t.includes('验证'));
|
||
} else if (qLower.includes('选型') || qLower.includes('成本') || qLower.includes('模型')) {
|
||
chosen = texts.findIndex(t => t.includes('成本') || t.includes('任务') || t.includes('性价比'));
|
||
} else if (qLower.includes('代码审查') || qLower.includes('review')) {
|
||
chosen = texts.findIndex(t => t.includes('安全') || t.includes('质量') || t.includes('逻辑'));
|
||
} else if (qLower.includes('幻觉') || qLower.includes('hallucination')) {
|
||
chosen = texts.findIndex(t => t.includes('事实') || t.includes('验证') || t.includes('核对'));
|
||
}
|
||
if (chosen < 0) chosen = 1;
|
||
|
||
await btns.nth(chosen).click();
|
||
await new Promise(r => setTimeout(r, 500));
|
||
console.log(` 👉 我选 ${String.fromCharCode(65 + chosen)}`);
|
||
|
||
// 提交
|
||
if (state.hasConfirmBtn) {
|
||
await page.locator('button:has-text("确认答案")').click();
|
||
console.log(' ✅ 提交');
|
||
}
|
||
|
||
// 如果有下一题的过渡
|
||
qIdx++;
|
||
|
||
} else if (state.hasTextarea) {
|
||
// ── 简答题 ──
|
||
console.log('✏️ 简答题,我来回答:\n');
|
||
|
||
// 根据题目内容生成回答
|
||
const qLower = question.toLowerCase();
|
||
let answer = '';
|
||
|
||
if (qLower.includes('测试') || qLower.includes('测试用例') || (qLower.includes('写代码') && qLower.includes('测试'))) {
|
||
answer = '我觉得即使有AI写代码,测试还是必须写的。AI写的代码也可能有bug,需要人工验证。测试不只是找bug,还能帮我们理解代码逻辑。而且测试用例本身也是需求的一部分,能告诉我们代码应该怎么用。AI可以帮我们写测试代码,但不能完全代替人去思考测试场景。';
|
||
} else if (qLower.includes('提示词') || qLower.includes('prompt')) {
|
||
answer = '写提示词要清晰具体,告诉AI它的角色是什么。可以给例子,让AI理解格式要求。复杂任务可以让AI一步一步思考。也要注意测试不同提示词的效果,找到最合适的。';
|
||
} else if (qLower.includes('安全') || qLower.includes('敏感') || qLower.includes('泄露')) {
|
||
answer = '要注意不要把敏感信息发给AI,比如密码、API密钥、客户数据。如果要用真实数据,要先脱敏处理。也要检查AI生成的代码有没有安全漏洞。';
|
||
} else if (qLower.includes('代码审查') || qLower.includes('review') || qLower.includes('代码质量')) {
|
||
answer = '代码评审要看代码的功能是否正确,有没有bug。还要看代码风格是否一致,性能好不好,有没有安全问题。AI可以帮我们做一部分检查,但最终还是需要人来做判断。';
|
||
} else if (qLower.includes('ai协作') || qLower.includes('ai合作') || qLower.includes('协作技巧')) {
|
||
answer = '和AI协作要分工明确,AI做它擅长的(生成、总结、分析),人做判断和决策。要给AI清晰的任务描述,分步骤沟通。AI生成的内容要自己核实,不能完全相信。';
|
||
} else {
|
||
answer = '我觉得首先要理解问题的本质,然后让AI帮忙分析。AI的输出要结合自己的经验和判断。不能完全依赖AI,要多验证AI给出的结果是否合理。安全意识和质量控制很重要。';
|
||
}
|
||
|
||
// 输入回答
|
||
await page.locator('textarea').first().click();
|
||
await page.evaluate((text) => {
|
||
const ta = document.querySelector('textarea');
|
||
if (!ta) return;
|
||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
||
setter?.call(ta, text);
|
||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, answer);
|
||
await new Promise(r => setTimeout(r, 800));
|
||
console.log(` 💬 "${answer.substring(0, 50)}..."`);
|
||
|
||
// 等 button 可用再点
|
||
await page.waitForFunction(() => {
|
||
const btn = document.querySelector('button:has(svg.lucide-send)');
|
||
return btn && !btn.disabled && btn.offsetParent !== null;
|
||
}, { timeout: 15000 }).catch(() => {});
|
||
await page.locator('button:has(svg.lucide-send)').last().click({ timeout: 8000 }).catch(() => {
|
||
page.locator('button:has(svg.lucide-send)').last().click({ force: true, timeout: 5000 }).catch(() => {});
|
||
});
|
||
console.log(' ✅ 已提交\n');
|
||
|
||
// 等批改
|
||
await waitForSpinner(page);
|
||
|
||
// 检查是否有追问
|
||
const stillTA = await page.evaluate(() => {
|
||
const ta = document.querySelector('textarea');
|
||
return ta !== null && ta.offsetParent !== null;
|
||
});
|
||
|
||
if (stillTA) {
|
||
console.log(' 🔄 AI追问来了!再回答一轮');
|
||
|
||
// 读追问的题目
|
||
const followQ = await getLastQuestion(page);
|
||
console.log(` 📖 追问: "${followQ.substring(0, 100)}..."`);
|
||
|
||
const followAnswer = '还要看代码的可维护性和可读性,团队协作时需要统一的代码风格。也要考虑性能优化和异常处理。总之AI是工具,人是决策者,不能把责任推给AI。';
|
||
await page.locator('textarea').first().click();
|
||
await page.evaluate((text) => {
|
||
const ta = document.querySelector('textarea');
|
||
if (!ta) return;
|
||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
||
setter?.call(ta, text);
|
||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, followAnswer);
|
||
await new Promise(r => setTimeout(r, 800));
|
||
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(() => {});
|
||
});
|
||
console.log(' ✅ 追问已回答');
|
||
await waitForSpinner(page);
|
||
}
|
||
|
||
qIdx++;
|
||
} else {
|
||
console.log(' ⏳ 等待...');
|
||
await new Promise(r => setTimeout(r, 3000));
|
||
continue;
|
||
}
|
||
|
||
// 等过渡
|
||
await waitForSpinner(page);
|
||
}
|
||
|
||
// 结果
|
||
console.log('\n═══ 考核结果 ═══');
|
||
await new Promise(r => setTimeout(r, 5000));
|
||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
|
||
|
||
const result = await page.evaluate(() => {
|
||
const body = document.body.textContent || '';
|
||
const scoreMatch = body.match(/(\d+\.?\d*)\/10/);
|
||
const levelMatch = body.match(/等级.*?(\w+)/i) || body.match(/LEVEL:\s*(\w+)/i);
|
||
const finalScoreMatch = body.match(/最终得分[::]\s*(\d+\.?\d*)/);
|
||
const passMatch = body.includes('合格') || body.includes('VERIFIED');
|
||
const failMatch = body.includes('不合格') || body.includes('FAIL');
|
||
// 各题得分
|
||
const allScores = Array.from(body.matchAll(/(\d+)\/10/g)).map(m => m[1]);
|
||
return {
|
||
score: scoreMatch?.[1] || finalScoreMatch?.[1] || '?',
|
||
level: levelMatch?.[1] || '?',
|
||
passed: passMatch,
|
||
failed: failMatch,
|
||
allScores: allScores.join(', '),
|
||
};
|
||
});
|
||
|
||
console.log(` 📊 各题得分: ${result.allScores || '无'}`);
|
||
console.log(` 🏆 等级: ${result.level}`);
|
||
console.log(` ${result.passed ? '🎉 合格!' : result.failed ? '😅 不合格...' : '...'}`);
|
||
|
||
await page.screenshot({ path: 'assessment-result-beginner.png', fullPage: true });
|
||
console.log(' 📸 截图已保存');
|
||
|
||
await browser.close();
|
||
console.log('\n=== 完成 ===');
|
||
}
|
||
|
||
run().catch(e => { console.error('\n❌', e.message); process.exit(1); });
|