/* 真实浏览器验证:只启动本机测试服务,不请求原网站。 */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const http = require('node:http'); const { chromium } = require('playwright'); const root = path.resolve(__dirname, '..'); const coreSource = fs.readFileSync(path.join(root, 'question-bank-core.js'), 'utf8'); const scriptSource = fs.readFileSync(path.join(root, '谊安学题库导出.user.js'), 'utf8'); const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-export-qa'); fs.mkdirSync(qaDir, { recursive: true }); const samplePath = path.join(root, '网页样本', '考试答案', '谊安学.html'); function option(label, text, selected = false) { return `
${label}${text}
`; } function fixture(title = '模拟练习', { missingAnswer = false, cardCount = 3 } = {}) { const content = [ { type: '单选', stem: '包含引号的项目 "甲,乙"?', options: [option('A', '错误选择', true), option('B', '正确选项')], answer: 'B', explanation: '请依据参考答案。' }, { type: '多选', stem: '选择符合条件的项目。', options: ['A', 'B', 'C', 'D', 'E'].map(x => option(x, '项目' + x)), answer: 'AE', explanation: '' }, { type: '判断', stem: '隐藏答案应能读取。', options: [option('A', '对'), option('B', '错')], answer: missingAnswer ? '' : 'B', explanation: '' } ].map((q, i) => `

[${q.type}] ${i + 1}. ${q.stem}查看答案

${q.options.join('')}
`).join(''); return `模拟谊安学练习
${title}
${content}
${Array.from({ length: cardCount }, (_, i) => `
${i + 1}
`).join('')}
`; } function parseCSV(text) { text = text.replace(/^\uFEFF/, ''); const rows = []; let row = []; let cell = ''; let quoted = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '"') { if (quoted && text[i + 1] === '"') { cell += '"'; i++; } else quoted = !quoted; } else if (!quoted && ch === ',') { row.push(cell); cell = ''; } else if (!quoted && (ch === '\r' || ch === '\n')) { if (ch === '\r' && text[i + 1] === '\n') i++; row.push(cell); rows.push(row); row = []; cell = ''; } else cell += ch; } if (cell || row.length) { row.push(cell); rows.push(row); } assert.equal(quoted, false, 'CSV 引号需成对'); return rows; } (async function main() { const server = http.createServer((req, res) => { const requestURL = new URL(req.url, 'http://localhost'); if (requestURL.pathname.endsWith('/question-bank-core.js')) { res.writeHead(200, { 'Content-Type': 'text/javascript;charset=utf-8' }); res.end(coreSource); } else if (requestURL.pathname.startsWith('/tool/')) { res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(fs.readFileSync(path.join(root, '离线导出题库.html'))); } else { res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(fixture(requestURL.searchParams.get('title') || '模拟练习')); } }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const origin = `http://127.0.0.1:${server.address().port}`; let browser; try { browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const context = await browser.newContext({ acceptDownloads: true, viewport: { width: 1366, height: 900 } }); const page = await context.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); }); const downloads = []; page.on('download', download => downloads.push(download)); const practiceURL = title => origin + '/clientcn/exam/' + encodeURIComponent(title) + '/questions?title=' + encodeURIComponent(title) + '&flag=exercise'; await page.goto(practiceURL('模拟练习')); await page.evaluate(() => { window.answerClicks = 0; }); const firstDownload = page.waitForEvent('download'); await page.addScriptTag({ content: scriptSource }); const downloaded = await firstDownload; await downloaded.saveAs(path.join(qaDir, 'synthetic.csv')); const bank = await page.evaluate(() => QuestionBankCore.extract(document)); assert.equal(bank.total, 3); assert.equal(bank.answered, 3); assert.equal(bank.complete, true); assert.equal(bank.questions[0].answer, 'B'); assert.equal(bank.questions[0].options[0].text, '错误选择'); assert.equal(bank.questions[1].answer, 'AE'); assert.equal(bank.questions[1].options.length, 5); assert.equal(bank.questions[2].answerText, 'B. 错'); assert.equal(bank.questions[0].explanation, '请依据参考答案。'); assert(bank.questions.every(q => !q.question.includes('查看答案'))); assert.equal(await page.evaluate(() => window.answerClicks), 0); const csv = fs.readFileSync(path.join(qaDir, 'synthetic.csv'), 'utf8'); assert.equal(csv.charCodeAt(0), 0xFEFF); const rows = parseCSV(csv); assert.equal(rows.length, 4); assert(rows.every(row => row.length === rows[0].length)); assert.equal(rows[1][rows[0].indexOf('题目')], '包含引号的项目 "甲,乙"?'); assert(rows[0].includes('选项E')); const formulaCSV = await page.evaluate(() => { const b = QuestionBankCore.extract(document); b.questions[0].question = '=HYPERLINK("http://invalid")'; return QuestionBankCore.toCSV(b); }); assert(parseCSV(formulaCSV)[1][2].startsWith("'=")); await page.waitForTimeout(2400); assert.equal(downloads.length, 1, '同一题库不应重复自动下载'); await page.screenshot({ path: path.join(qaDir, 'practice-desktop.png') }); console.log('PASS 隐藏答案、错误已选项隔离、多选、判断、完整CSV与单次自动下载'); // 新 URL 与新 DOM 在下一次扫描前一同更新,仍应成功自动导出。 const nextDownload = page.waitForEvent('download'); await page.evaluate(({ url, html }) => { history.pushState({}, '', url); const doc = new DOMParser().parseFromString(html, 'text/html'); document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest')); }, { url: practiceURL('第二份练习'), html: fixture('第二份练习') }); assert.equal((await nextDownload).suggestedFilename(), '第二份练习_题库_3题.csv'); console.log('PASS SPA 页面与地址同步切换自动导出'); // URL 先改变、旧内容仍在时不得导出旧题;题目延后到达再导出。 const beforeDelayed = downloads.length; await page.evaluate(url => history.pushState({}, '', url), practiceURL('延迟练习')); await page.waitForTimeout(2600); assert.equal(downloads.length, beforeDelayed); const delayedDownload = page.waitForEvent('download'); await page.evaluate(html => { const doc = new DOMParser().parseFromString(html, 'text/html'); document.querySelector('app-examtest').innerHTML = doc.querySelector('app-examtest').innerHTML; }, fixture('延迟练习')); assert.equal((await delayedDownload).suggestedFilename(), '延迟练习_题库_3题.csv'); console.log('PASS SPA 旧页面残留与延迟加载等待'); const beforeMissing = downloads.length; await page.evaluate(({ url, html }) => { history.pushState({}, '', url); document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML; }, { url: practiceURL('缺答案练习'), html: fixture('缺答案练习', { missingAnswer: true }) }); await page.waitForTimeout(2600); assert.equal(downloads.length, beforeMissing); assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('缺少 1 个答案')); const manual = page.waitForEvent('download'); await page.getByRole('button', { name: '导出 CSV', exact: true }).click(); assert((await manual).suggestedFilename().includes('_不完整')); const missing = await page.evaluate(() => QuestionBankCore.extract(document)); assert.equal(missing.complete, false); assert.deepEqual(missing.missingAnswers, [3]); console.log('PASS 缺答案不自动下载,手动导出标记不完整'); await page.evaluate(({ url, html }) => { history.pushState({}, '', url); document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML; }, { url: practiceURL('缺题练习'), html: fixture('缺题练习', { cardCount: 4 }) }); const beforeCount = downloads.length; await page.waitForTimeout(2600); assert.equal(downloads.length, beforeCount); assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('3 / 4')); await page.evaluate(() => history.pushState({}, '', location.href.replace('flag=exercise', 'flag=exam'))); await page.waitForTimeout(1100); assert.equal(await page.locator('#yian-question-bank-exporter').count(), 0); assert.equal(await page.evaluate(() => window.answerClicks), 0); console.log('PASS 缺题等待、考试页面不触发、不改变答题状态'); const sampleHTML = fs.existsSync(samplePath) ? fs.readFileSync(samplePath, 'utf8') : fixture(); const tool = await context.newPage(); tool.on('pageerror', error => errors.push(error.message)); tool.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); }); const toolURL = require('node:url').pathToFileURL(path.join(root, '离线导出题库.html')).href; await tool.goto(toolURL); assert.equal(tool.url(), toolURL); assert.equal(await tool.title(), '离线导出题库'); assert(await tool.getByRole('heading', { name: '从已保存的练习网页导出题库' }).isVisible()); const sourceRequests = []; tool.on('request', request => { if (!request.url().startsWith(origin)) sourceRequests.push(request.url()); }); await tool.locator('#file').setInputFiles({ name: '谊安学.html', mimeType: 'text/html', buffer: Buffer.from(sampleHTML) }); await tool.locator('#csv').waitFor({ state: 'visible' }); await tool.waitForFunction(() => !document.getElementById('csv').disabled); assert.equal(sourceRequests.length, 0, '解析网页时不应请求原网站资源'); const realBank = await tool.evaluate(html => QuestionBankCore.extract(QuestionBankCore.parseHTML(html)), sampleHTML); if (fs.existsSync(samplePath)) { assert.equal(realBank.total, 120); assert.equal(realBank.answered, 120); assert.equal(realBank.expectedTotal, 120); assert.equal(realBank.complete, true); assert.deepEqual(realBank.questions.reduce((o, q) => (o[q.type] = (o[q.type] || 0) + 1, o), {}), { 单选: 40, 多选: 40, 判断: 40 }); assert.equal(realBank.questions[0].answer, 'C'); assert.equal(realBank.questions[0].options[0].text, '2种'); assert.equal(realBank.questions[20].answer, 'AB'); assert.equal(realBank.questions[40].answerText, 'A. 对'); assert.equal(realBank.questions[100].answerText, 'B. 错'); assert(realBank.questions.every(q => q.explanation === '')); assert.deepEqual(realBank.questions.map(q => q.number), Array.from({ length: 120 }, (_, i) => i + 1)); console.log('PASS 真实样本120题120答案,三种题型各40题、全部解析为空'); } for (const format of ['csv', 'json', 'txt']) { const promise = tool.waitForEvent('download'); await tool.locator('#' + format).click(); const file = await promise; await file.saveAs(path.join(qaDir, file.suggestedFilename())); assert((await file.failure()) === null); } await tool.screenshot({ path: path.join(qaDir, 'offline-desktop.png'), fullPage: true }); await tool.setViewportSize({ width: 390, height: 844 }); assert(await tool.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)); await tool.screenshot({ path: path.join(qaDir, 'offline-mobile.png'), fullPage: true }); console.log('PASS 离线工具文件导入、三格式下载、无联网、桌面与手机布局'); // 验证原网页的文档级保存注释可以激活 file: 模式。 const local = await context.newPage(); const localHTML = '' + fixture('本地练习'); const localPath = path.join(qaDir, 'local-practice.html'); fs.writeFileSync(localPath, localHTML); await local.goto(require('node:url').pathToFileURL(localPath).href); const localDownload = local.waitForEvent('download'); await local.addScriptTag({ content: scriptSource }); assert.equal((await localDownload).suggestedFilename(), '本地练习_题库_3题.csv'); assert.equal(errors.length, 0, '浏览器不应有控制台/运行时错误:' + errors.join('\n')); console.log('PASS 本地保存网页自动识别与浏览器错误检查'); console.log('验证截图和下载文件:' + qaDir); } finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); } })().catch(error => { console.error(error); process.exitCode = 1; });