fix: shuffleArray bug + Playwright多轮对话测试 + 初学者考核脚本
- 修复 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>
This commit is contained in:
@@ -179,6 +179,28 @@ docker-compose up -d # Builds and starts all services
|
||||
- Tika: 9998
|
||||
- LibreOffice: 8100
|
||||
|
||||
## AI 工作流指令
|
||||
|
||||
本项目已安装 **gstack**(54 个技能)和 **Superpowers**(14 个技能)。请按以下规则协调使用:
|
||||
|
||||
### 自动触发规则
|
||||
当用户的意图匹配以下场景时,**自动调用对应的 gstack skill**(使用 Skill 工具),并在调用前向用户说明启动了哪个技能:
|
||||
|
||||
- 用户讨论需求、想法、产品方向 → 调用 `office-hours` skill,说"正在启动 **office-hours**(产品策略顾问)..."
|
||||
- 用户讨论功能范围、优先级 → 调用 `plan-ceo-review` skill,说"正在启动 **plan-ceo-review**(战略评审)..."
|
||||
- 用户讨论技术方案、架构设计 → 调用 `plan-eng-review` skill,说"正在启动 **plan-eng-review**(架构评审)..."
|
||||
- 用户要求审查代码 → 调用 `review` skill,说"正在启动 **review**(代码审查)..."
|
||||
- 用户要求测试/QA → 调用 `qa` skill,说"正在启动 **qa**(自动化测试)..."
|
||||
- 用户要求安全审查 → 调用 `cso` skill,说"正在启动 **cso**(安全审计)..."
|
||||
- 用户要求发布/发版 → 调用 `ship` skill,说"正在启动 **ship**(发布流程)..."
|
||||
- 用户报告 bug 需要调试 → 调用 `investigate` skill,说"正在启动 **investigate**(系统化调试)..."
|
||||
|
||||
### Superpowers 保留自动触发
|
||||
Superpowers 的技能(brainstorming、test-driven-development、systematic-debugging 等)继续保持原有自动触发机制,不做干预。
|
||||
|
||||
### 通知机制
|
||||
每次自动调用 gstack skill 时,必须明确告知用户正在启动哪个技能及其作用。
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:13001';
|
||||
|
||||
async function run() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
// 登录
|
||||
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('**/');
|
||||
|
||||
// 进入考核页(首页会显示历史记录侧栏)
|
||||
await page.goto(`${BASE}/assessment`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 截图1:首页(含历史记录侧栏)
|
||||
await page.screenshot({ path: 'assessment-overview.png', fullPage: true });
|
||||
console.log('📸 1/5 首页截图(含历史侧栏)保存');
|
||||
|
||||
// 看看历史记录里有什么
|
||||
const historyInfo = await page.evaluate(() => {
|
||||
const items = Array.from(document.querySelectorAll('.w-80 div.space-y-3 > div'));
|
||||
return items.map(el => ({
|
||||
text: (el.textContent || '').replace(/\s+/g, ' ').trim(),
|
||||
}));
|
||||
});
|
||||
console.log('\n📋 历史记录:');
|
||||
historyInfo.forEach((h, i) => console.log(` [${i+1}] ${h.text}`));
|
||||
|
||||
if (historyInfo.length === 0) {
|
||||
console.log(' 没有历史记录,可能是空状态');
|
||||
await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击第一条历史记录查看详情(选分数最高的那条)
|
||||
// 找分数最高的:解析数字
|
||||
let bestIdx = 0;
|
||||
let bestScore = -1;
|
||||
historyInfo.forEach((h, i) => {
|
||||
const m = h.text.match(/([\d.]+)\/10/);
|
||||
if (m) {
|
||||
const s = parseFloat(m[1]);
|
||||
if (s > bestScore) { bestScore = s; bestIdx = i; }
|
||||
}
|
||||
});
|
||||
console.log(`\n🔍 选择分数最高的记录 #${bestIdx+1} (${bestScore}/10)`);
|
||||
|
||||
// 历史记录在右侧边栏,每条记录最右边有个查看按钮(FileText图标)
|
||||
const histButtons = page.locator('.w-80 div.space-y-3 > div button');
|
||||
const btnCount = await histButtons.count();
|
||||
console.log(` 右侧历史栏共有 ${btnCount} 个按钮`);
|
||||
|
||||
// 每条记录有2个按钮(删除+查看),查看按钮在最后
|
||||
// 第N条记录的查看按钮索引 = (N * 2 + 1) (从0开始)
|
||||
const viewBtnIdx = bestIdx * 2 + 1;
|
||||
if (viewBtnIdx < btnCount) {
|
||||
await histButtons.nth(viewBtnIdx).click();
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
// 截图2:历史考核详情页
|
||||
await page.screenshot({ path: 'assessment-history-detail.png', fullPage: true });
|
||||
console.log('📸 2/5 考核详情页截图');
|
||||
|
||||
// 看看详情页有什么内容
|
||||
const detailInfo = await page.evaluate(() => {
|
||||
const body = document.body.textContent || '';
|
||||
const scoreMatch = body.match(/([\d.]+)\/10/g);
|
||||
const levelMatch = body.match(/(?:LEVEL|等级)[::]\s*(\w+)/i);
|
||||
const reportSection = body.includes('综合报告') || body.includes('comprehensive');
|
||||
const detailSection = body.includes('每题详情') || body.includes('details');
|
||||
const hasPassed = body.includes('合格') || body.includes('VERIFIED');
|
||||
|
||||
// 按钮文字
|
||||
const btns = Array.from(document.querySelectorAll('button'))
|
||||
.map(b => (b.textContent || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return { scores: scoreMatch, level: levelMatch?.[1], reportSection, detailSection, hasPassed, btns };
|
||||
});
|
||||
|
||||
console.log(`\n📊 得分列表: ${detailInfo.scores?.join(', ') || '无'}`);
|
||||
console.log(`🏆 等级: ${detailInfo.level || '未显示'}`);
|
||||
console.log(`✅ 合格: ${detailInfo.hasPassed ? '是' : '否'}`);
|
||||
console.log(`📋 每题详情: ${detailInfo.detailSection ? '✅ 有' : '❌ 无'}`);
|
||||
console.log(`📝 综合报告: ${detailInfo.reportSection ? '✅ 有' : '❌ 无'}`);
|
||||
console.log(`\n🔘 按钮列表:`);
|
||||
detailInfo.btns.forEach(b => console.log(` - ${b}`));
|
||||
|
||||
// 找"查看证书"按钮
|
||||
const certBtnText = detailInfo.btns.find(b =>
|
||||
b.includes('证书') || b.includes('Certificate') || b.includes('certificate')
|
||||
);
|
||||
console.log(`\n🔖 证书按钮: ${certBtnText || '没找到'}`);
|
||||
|
||||
// 如果有证书按钮,点击它
|
||||
if (certBtnText) {
|
||||
const certBtn = page.locator('button', { hasText: /证书|Certificate|certificate/ });
|
||||
if (await certBtn.isVisible().catch(() => false)) {
|
||||
await certBtn.click();
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// 截图3:证书弹窗
|
||||
await page.screenshot({ path: 'assessment-certificate-modal.png', fullPage: true });
|
||||
console.log('📸 3/5 证书弹窗截图');
|
||||
|
||||
// 读取证书弹窗内容
|
||||
const certData = await page.evaluate(() => {
|
||||
// 找 portal(弹窗在 document.body 最下层)
|
||||
const modal = document.querySelector('.fixed.inset-0.z-\\[1000\\]');
|
||||
if (!modal) return { found: false };
|
||||
|
||||
const text = modal.textContent || '';
|
||||
const level = text.match(/(\w+)/)?.[1] || '';
|
||||
const totalScore = text.match(/([\d.]+)\/10/)?.[1] || '';
|
||||
const dimScores = Array.from(text.matchAll(/(\w+)\s*([\d.]+)\/10/g))
|
||||
.map(m => `${m[1]}: ${m[2]}/10`);
|
||||
const questionCount = text.match(/题目列表[\s\S]*?#(\d+)/)?.[1] || '';
|
||||
|
||||
return {
|
||||
found: true,
|
||||
text: text.substring(0, 500),
|
||||
level, totalScore, dimScores, questionCount,
|
||||
};
|
||||
});
|
||||
|
||||
if (certData.found) {
|
||||
console.log(`\n📜 证书内容:`);
|
||||
console.log(` 等级: ${certData.level}`);
|
||||
console.log(` 总分: ${certData.totalScore}/10`);
|
||||
console.log(` 维度得分: ${certData.dimScores.join(', ') || '无'}`);
|
||||
console.log(` 题目数: ${certData.questionCount || '未知'}`);
|
||||
}
|
||||
|
||||
// 关掉弹窗
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
// 看 PDF 和 Excel 导出
|
||||
const hasPdf = detailInfo.btns.some(b => b.includes('PDF'));
|
||||
const hasExcel = detailInfo.btns.some(b => b.includes('Excel') || b.includes('excel'));
|
||||
console.log(`\n📄 PDF下载: ${hasPdf ? '✅ 有' : '❌ 无'}`);
|
||||
console.log(`📊 Excel导出: ${hasExcel ? '✅ 有' : '❌ 无'}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log('\n=== 完成 ===');
|
||||
}
|
||||
run().catch(e => { console.error('❌', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,267 @@
|
||||
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); });
|
||||
+4
-1
@@ -11,5 +11,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,8 +541,8 @@ export class QuestionBankService {
|
||||
for (const dw of dimensionWeights) {
|
||||
const dimName = dw.name as QuestionDimension;
|
||||
const targetForDim = Math.round(count * dw.weight / totalWeight);
|
||||
const pool = availableItems.filter(i => i.dimension === dimName && !usedIds.has(i.id));
|
||||
this.shuffleArray(pool);
|
||||
let pool = availableItems.filter(i => i.dimension === dimName && !usedIds.has(i.id));
|
||||
pool = this.shuffleArray(pool);
|
||||
const take = Math.min(targetForDim, pool.length);
|
||||
for (let i = 0; i < take; i++) {
|
||||
selected.push(pool[i]);
|
||||
@@ -550,7 +550,7 @@ export class QuestionBankService {
|
||||
}
|
||||
}
|
||||
availableItems = availableItems.filter(i => !usedIds.has(i.id));
|
||||
this.shuffleArray(availableItems);
|
||||
availableItems = this.shuffleArray(availableItems);
|
||||
while (selected.length < count && availableItems.length > 0) {
|
||||
const item = availableItems.pop()!;
|
||||
selected.push(item);
|
||||
@@ -572,7 +572,7 @@ export class QuestionBankService {
|
||||
if (dimIdx >= DIMENSIONS.length * 3) break;
|
||||
}
|
||||
if (selected.length < count && availableItems.length > 0) {
|
||||
this.shuffleArray(availableItems);
|
||||
availableItems = this.shuffleArray(availableItems);
|
||||
for (const item of availableItems) {
|
||||
if (selected.length >= count) break;
|
||||
if (!usedIds.has(item.id)) {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:13001';
|
||||
const SA_REPLIES = ['需要审查代码质量和安全性', '还要检查逻辑正确性和性能问题'];
|
||||
|
||||
/** Fill a textarea via native setter + input event (reliable for React controlled inputs) */
|
||||
async function fillTextarea(page, text) {
|
||||
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));
|
||||
}
|
||||
|
||||
/** Click the send button (wait for it to be enabled, then regular or force click) */
|
||||
async function clickSendButton(page) {
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const btn = document.querySelector('button:has(svg.lucide-send)');
|
||||
return btn && !btn.disabled && btn.offsetParent !== null;
|
||||
}, { timeout: 15000 });
|
||||
await page.locator('button:has(svg.lucide-send)').last().click({ timeout: 5000 });
|
||||
} catch {
|
||||
// Regular click failed (stale/detached element); wait briefly and force
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
await page.locator('button:has(svg.lucide-send)').last().click({ force: true, timeout: 5000 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('=== AuraK 考核多轮对话测试 ===\n');
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
// Login
|
||||
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(' ✅ 登录成功');
|
||||
|
||||
// Assessment page
|
||||
await page.goto(`${BASE}/assessment`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Select template
|
||||
await page.locator('button:has-text("AI协作技巧")').first().click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator('button:has-text("开始专业评估")').first().click();
|
||||
|
||||
// Wait for first question
|
||||
console.log('[2] 等待出题...');
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const text = await page.textContent('body').catch(() => '');
|
||||
if (text.includes('问题 ') || text.includes('Question ')) break;
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
console.log(' ✅ 第 1 题已出现');
|
||||
|
||||
// Wait spinner to finish
|
||||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// Answer questions
|
||||
let qIdx = 1;
|
||||
let saCount = 0, followUpCount = 0;
|
||||
const totalQs = 4;
|
||||
|
||||
while (qIdx <= totalQs) {
|
||||
const state = await page.evaluate(() => {
|
||||
const choiceBtns = Array.from(document.querySelectorAll('button'))
|
||||
.filter(b => /^[A-D]/.test(b.textContent || '') && (b.textContent || '').length > 5)
|
||||
.filter(b => !(b.textContent || '').startsWith('AuraK'))
|
||||
.filter(b => !(b.textContent || '').startsWith('Admin'));
|
||||
|
||||
const ta = document.querySelector('textarea');
|
||||
return {
|
||||
choiceCount: choiceBtns.length,
|
||||
hasTextarea: ta !== null && ta.offsetParent !== null,
|
||||
firstChoice: choiceBtns[0]?.textContent || '',
|
||||
busy: document.querySelector('.animate-spin') !== null,
|
||||
};
|
||||
});
|
||||
|
||||
if (state.busy) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state.choiceCount > 0) {
|
||||
// ── CHOICE ──
|
||||
console.log(`\n 🟦 第 ${qIdx}/${totalQs} 题 (选择) "${state.firstChoice.substring(0, 30)}..."`);
|
||||
|
||||
const btns = page.locator('button.w-full.text-left.px-5.py-4');
|
||||
const count = await btns.count();
|
||||
if (count > 0) {
|
||||
await btns.first().click();
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
const confirm = page.locator('button:has-text("确认答案")');
|
||||
if (await confirm.isVisible().catch(() => false)) {
|
||||
await confirm.click();
|
||||
console.log(` ✅ 已提交`);
|
||||
}
|
||||
qIdx++;
|
||||
} else if (state.hasTextarea) {
|
||||
// ── SHORT ANSWER ──
|
||||
saCount++;
|
||||
const replyIdx = Math.min(saCount - 1, SA_REPLIES.length - 1);
|
||||
console.log(`\n 🟩 第 ${qIdx}/${totalQs} 题 (简答 #${saCount})`);
|
||||
|
||||
await page.locator('textarea').first().click();
|
||||
await fillTextarea(page, SA_REPLIES[replyIdx]);
|
||||
console.log(` 📝 已输入: "${SA_REPLIES[replyIdx].substring(0, 20)}..."`);
|
||||
await clickSendButton(page);
|
||||
console.log(` ✅ 已提交`);
|
||||
|
||||
// Wait for grading
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// Check for follow-up: textarea visible again at same question position
|
||||
const stillTA = await page.evaluate(() => {
|
||||
const ta = document.querySelector('textarea');
|
||||
return ta !== null && ta.offsetParent !== null;
|
||||
});
|
||||
|
||||
if (stillTA && followUpCount < SA_REPLIES.length - 1) {
|
||||
followUpCount++;
|
||||
const fReply = SA_REPLIES[Math.min(followUpCount, SA_REPLIES.length - 1)];
|
||||
console.log(` 🔄 AI 追问 #${followUpCount} 已触发!`);
|
||||
|
||||
await page.locator('textarea').first().click();
|
||||
await fillTextarea(page, fReply);
|
||||
console.log(` 📝 追问已输入: "${fReply.substring(0, 20)}..."`);
|
||||
await clickSendButton(page);
|
||||
console.log(` ✅ 追问已提交`);
|
||||
|
||||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 60000 }).catch(() => {});
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
qIdx++;
|
||||
} else {
|
||||
// Wait for anything to appear
|
||||
console.log(` ⏳ 等待第 ${qIdx} 题...`);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait for next question
|
||||
await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 30000 }).catch(() => {});
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
// Results
|
||||
await new Promise(r => setTimeout(r, 4000));
|
||||
const body = await page.textContent('body');
|
||||
const scores = body.match(/\d+\/10/g);
|
||||
|
||||
console.log(`\n 📊 结果:`);
|
||||
console.log(` 选择题: ${totalQs - saCount}`);
|
||||
console.log(` 简答题: ${saCount}`);
|
||||
console.log(` AI追问: ${followUpCount}`);
|
||||
console.log(` 分数: ${scores ? scores.join(', ') : '无'}`);
|
||||
|
||||
if (followUpCount > 0) {
|
||||
console.log(`\n 🎉 多轮对话正常工作!`);
|
||||
} else if (saCount > 0) {
|
||||
console.log(`\n ✅ 简答题正常回答,未触发追问(回答已完整)。`);
|
||||
} else {
|
||||
console.log(`\n ⚠️ 未遇到简答题,需要确认 shuffle 是否生效。`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log('\n=== 完成 ===');
|
||||
}
|
||||
|
||||
run().catch(e => { console.error('\n❌', e.message); process.exit(1); });
|
||||
Reference in New Issue
Block a user