251 lines
19 KiB
JavaScript
251 lines
19 KiB
JavaScript
/* 真实浏览器验证:主站 URL 仅由本机服务模拟,不请求业务服务器。 */
|
||
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 { pathToFileURL } = require('node:url');
|
||
|
||
const root = path.resolve(__dirname, '..');
|
||
const coreSource = fs.readFileSync(path.join(root, 'question-bank-core.js'), 'utf8');
|
||
const scriptSource = fs.readFileSync(path.join(root, '谊安学助手 2.0.user.js'), 'utf8');
|
||
assert.deepEqual(Array.from(scriptSource.matchAll(/^\/\/ @match\s+(.+)$/gm), match => match[1].trim()), ['http://10.119.67.7/clientcn/*']);
|
||
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 `<div class="pc_examtest_content_list_child1"><div class="pc_examtest_content_list_des${selected ? ' examtest_content_list_des_checked' : ''}" onclick="window.answerClicks++"><span class="examtest_content_list_des_optionsort">${label}</span>${text}</div></div>`;
|
||
}
|
||
function fixture(title = '模拟练习', { missingAnswer = false, cardCount = 3 } = {}) {
|
||
const content = [
|
||
{ type: '单选', stem: '包含引号的项目 "甲,乙"?', options: [option('A', '错误选择', true), option('B', '正确选项')], answer: '<span class="answer">B</span>', explanation: '请依据参考答案。' },
|
||
{ type: '多选', stem: '选择符合条件的项目。', options: ['A', 'B', 'C', 'D', 'E'].map(x => option(x, '项目' + x)), answer: '<span class="answer">A</span><span class="answer">E</span>', explanation: '' },
|
||
{ type: '判断', stem: '隐藏答案应能读取。', options: [option('A', '对'), option('B', '错')], answer: missingAnswer ? '' : '<span class="answer">B</span>', explanation: '' }
|
||
].map((q, i) => `<div class="pc_exam_list" app-exam-question><a id="examtest${i}"></a><div class="pc_examtest_content"><div class="pc_examtest_content_question"><p><span>[${q.type}]</span> ${i + 1}. ${q.stem}<span class="pc_examtest_content_question_showanswer" onclick="window.answerClicks++">查看答案</span></p></div><div class="pc_examtest_content_list">${q.options.join('')}</div><div class="hidden"><div class="pc_examtest_content_answer_top"><div class="examtest_content_answer_left">参考答案:${q.answer}</div><div>答错了</div></div><div class="pc_examtest_content_resolve">题目解析:<span>${q.explanation}</span></div></div></div></div>`).join('');
|
||
return `<!doctype html><title>模拟谊安学练习</title><style>.hidden{display:none}body{font:16px system-ui;margin:30px}.pc_examtest_content{padding:15px;border-bottom:1px solid #ddd}.pc_examtest_title{font-size:24px}</style><app-examtest><div class="pc_examtest_title">${title}</div>${content}<div class="pc_examtest_card">${Array.from({ length: cardCount }, (_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div></app-examtest>`;
|
||
}
|
||
|
||
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 localOrigin = `http://127.0.0.1:${server.address().port}`;
|
||
const origin = 'http://10.119.67.7';
|
||
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 externalRequests = [];
|
||
const toolURL = pathToFileURL(path.join(root, '离线导出题库.html')).href;
|
||
const coreURL = pathToFileURL(path.join(root, 'question-bank-core.js')).href;
|
||
await context.route('**/*', async route => {
|
||
const url = new URL(route.request().url());
|
||
if (url.origin === origin) {
|
||
const response = await route.fetch({ url: localOrigin + url.pathname + url.search });
|
||
return route.fulfill({ response });
|
||
}
|
||
if (url.origin === localOrigin || url.href === toolURL || url.href === coreURL || url.protocol === 'file:' && url.href.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue();
|
||
externalRequests.push(url.href); return route.abort();
|
||
});
|
||
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; });
|
||
await page.addScriptTag({ content: scriptSource });
|
||
await page.evaluate(() => YianAssistant.ready);
|
||
await page.waitForFunction(() => YianAssistant.getState().pageReady);
|
||
await page.waitForTimeout(800);
|
||
assert.equal(downloads.length, 0, '导出开关默认关闭,不应自动下载');
|
||
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
|
||
assert(await page.getByRole('button', { name: '导出 CSV', exact: true, includeHidden: true }).isHidden());
|
||
const blockedMessage = await page.evaluate(() => {
|
||
try { YianAssistant.exportCurrent('csv'); return ''; } catch (error) { return error.message; }
|
||
});
|
||
assert(blockedMessage.includes('开关'), '开关关闭时导出入口必须拒绝手动下载');
|
||
const firstDownload = page.waitForEvent('download');
|
||
await page.getByRole('switch', { name: '导出题库', exact: true }).check();
|
||
const downloaded = await firstDownload;
|
||
assert(await page.getByRole('button', { name: '导出 CSV', exact: true }).isVisible());
|
||
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-assistant .page-status').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-assistant .page-status').innerText()).includes('3 / 4'));
|
||
await page.evaluate(() => history.pushState({}, '', location.href.replace('flag=exercise', 'flag=exam')));
|
||
await page.waitForTimeout(1100); assert(await page.getByRole('button', { name: '导出 CSV', exact: true }).isDisabled());
|
||
assert.equal(await page.evaluate(() => window.answerClicks), 0);
|
||
console.log('PASS 缺题等待、未知考试模式不触发、不改变答题状态');
|
||
|
||
await page.getByRole('switch', { name: '导出题库', exact: true }).uncheck();
|
||
assert(await page.getByRole('button', { name: '导出 CSV', exact: true, includeHidden: true }).isHidden());
|
||
const beforeClosedRoute = 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('开关关闭练习') });
|
||
await page.waitForFunction(() => YianAssistant.getState().pageReady);
|
||
await page.waitForTimeout(800);
|
||
assert.equal(downloads.length, beforeClosedRoute, '开关关闭时新练习也不自动导出');
|
||
const reopening = page.waitForEvent('download');
|
||
await page.getByRole('switch', { name: '导出题库', exact: true }).check();
|
||
assert.equal((await reopening).suggestedFilename(), '开关关闭练习_题库_3题.csv');
|
||
await page.getByRole('switch', { name: '导出题库', exact: true }).uncheck();
|
||
const closedManual = await page.evaluate(() => {
|
||
try { YianAssistant.exportCurrent('json'); return ''; } catch (error) { return error.message; }
|
||
});
|
||
assert(closedManual.includes('开关'));
|
||
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()); });
|
||
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();
|
||
local.on('pageerror', error => errors.push(error.message));
|
||
local.on('download', download => downloads.push(download));
|
||
const localHTML = '<!doctype html><!-- saved from url=(0100)http://10.119.67.7/clientcn/exam/local/questions?flag=exercise -->' + fixture('本地练习').replace('<app-examtest>', '<body onselectstart="return false" oncontextmenu="return false" style="user-select:none"><app-examtest>');
|
||
const localPath = path.join(qaDir, 'local-practice.html'); fs.writeFileSync(localPath, localHTML);
|
||
await local.goto(pathToFileURL(localPath).href);
|
||
const beforeBody = await local.evaluate(() => ({ html: document.body.innerHTML, select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), style: document.body.getAttribute('style') }));
|
||
const beforeLocal = downloads.length;
|
||
await local.addScriptTag({ content: scriptSource });
|
||
await local.waitForTimeout(150);
|
||
assert.equal(await local.locator('#yian-question-assistant').count(), 0);
|
||
assert.deepEqual(await local.evaluate(() => [typeof window.JSZip, typeof window.QuestionBankCore, typeof window.YianAssistant]), ['undefined', 'undefined', 'undefined']);
|
||
assert.deepEqual(await local.evaluate(() => ({ html: document.body.innerHTML, select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), style: document.body.getAttribute('style') })), beforeBody);
|
||
assert.equal(downloads.length, beforeLocal, 'file 保存网页不能自动下载');
|
||
assert.deepEqual(externalRequests, [], '业务 URL 均通过本机模拟;不得请求其他网站');
|
||
assert.equal(errors.length, 0, '浏览器不应有控制台/运行时错误:' + errors.join('\n'));
|
||
console.log('PASS file 保存网页不注入/下载或改变复制限制;独立离线导出工具保持可用,无外部请求与浏览器错误');
|
||
console.log('验证截图和下载文件:' + qaDir);
|
||
} finally {
|
||
if (browser) await browser.close();
|
||
await new Promise(resolve => server.close(resolve));
|
||
}
|
||
})().catch(error => { console.error(error); process.exitCode = 1; });
|