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:
Developer
2026-06-08 22:34:04 +08:00
parent 0b2c6563ba
commit c57c3028e2
6 changed files with 640 additions and 5 deletions
+22
View File
@@ -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
+156
View File
@@ -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); });
+267
View File
@@ -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
View File
@@ -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)) {
+187
View File
@@ -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); });