Files
yianxue-assistant/tests/assistant.cjs
T
2026-10-10 03:39:43 +08:00

181 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 完整面板与跨网址存储验证;GM 桥接仅用于本机模拟,不请求业务服务器。 */
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 workspace = path.resolve(__dirname, '..');
const script = fs.readFileSync(path.join(workspace, '谊安学题库导出.user.js'), 'utf8');
assert(script.includes('// @match *://*/*'));
assert(!/\/\/ @match\s+https?:\/\/10\./.test(script));
const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-assistant-qa');
fs.mkdirSync(qaDir, { recursive: true });
const questions = [
{ type: '单选', stem: '广西华谊新材料有限公司涉及几种高危工艺?', options: ['5种', '4种', '3种', '2种'] },
{ type: '多选', stem: '以下哪些属于广西华谊新材料有限公司涉及的高危工艺?', options: ['聚合', '蒸馏', '氧化', '裂解'] },
{ type: '判断', stem: '安全不仅是企业的底线,更是华谊对员工、对社会的庄严承诺。', options: ['错', '对'] }
];
async function bankInput() {
const zipPath = path.join(workspace, '题库.zip');
if (fs.existsSync(zipPath) && process.env.YIAN_TEST_SYNTHETIC_BANK !== '1') return { file: zipPath, total: 5520, counts: { 主操: 1620, 班长: 2340, 副操: 1560 }, real: true };
const JSZip = require(path.join(workspace, 'vendor', 'jszip.min.js'));
const zip = new JSZip();
for (const role of ['主操', '班长', '副操']) {
const records = questions.map((q, index) => ({ role, course: '安全领导力-1', number: index + 1,
type: q.type, question: q.stem, options: [['2种', '3种', '4种', '5种'], ['裂解', '氧化', '聚合', '蒸馏'], ['对', '错']][index]
.map((text, i) => ({ label: String.fromCharCode(65 + i), text })), answer: ['C', 'AB', 'A'][index] }));
for (const [index, course] of ['风险管理', '重大危险源安全管理'].entries()) records.push({ role, course, number: 1,
type: '多选', question: '常压罐组收料作业中,以下哪些属于工程控制措施?',
options: ['装置甲', '装置乙', '装置丙', '防火堤'].map((text, i) => ({ label: String.fromCharCode(65 + i), text })), answer: index ? 'ABCD' : 'ABC' });
for (let i = 0; i < 25; i++) records.push({ role, course: '作业课程', number: i + 1, type: '单选',
question: '作业检索测试题' + i, options: [{ label: 'A', text: '正确项' }, { label: 'B', text: '错误项' }], answer: 'A' });
zip.file(role + '/测试题库.json', JSON.stringify(records));
}
return { file: { name: '测试题库.zip', mimeType: 'application/zip', buffer: await zip.generateAsync({ type: 'nodebuffer' }) },
total: 90, counts: { 主操: 30, 班长: 30, 副操: 30 }, real: false };
}
function practiceHTML() {
return `<app-examtest><div class="pc_examtest_title">安全领导力-1</div>${questions.map((q, index) =>
`<div app-exam-question class="pc_exam_list"><a id="examtest${index}"></a><div class="pc_examtest_content" data-kind="${q.type}" data-number="${index + 1}"><div class="pc_examtest_content_question"><p>[${q.type}] ${index + 1}. ${q.stem}<span class="pc_examtest_content_question_showanswer">查看答案</span></p></div><div class="pc_examtest_content_list">${q.options.map((text, i) => `<div class="pc_examtest_content_list_child1"><div class="pc_examtest_content_list_des${index === 1 && i === 0 ? ' examtest_content_list_des_checked' : ''}"><span class="examtest_content_list_des_optionsort">${String.fromCharCode(65 + i)}</span>${text}</div></div>`).join('')}</div><div class="hidden"><div class="examtest_content_answer_left">参考答案:<span class="answer"></span></div><div class="pc_examtest_content_resolve">题目解析:</div></div></div></div>`).join('')}<div class="pc_examtest_card">${questions.map((_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><div class="pc_examtest_commit">重新练习</div><button id="submit">交卷</button></app-examtest>`;
}
function documentHTML(practice) {
return `<!doctype html><meta charset="utf-8"><title>${practice ? '助手练习验证' : '普通网页验证'}</title><style>body{font:16px system-ui;margin:32px;background:#f4f7fa;user-select:none}.pc_examtest_content{padding:16px;background:white;border-bottom:1px solid #ddd;max-width:750px}.pc_examtest_content_list_des{padding:8px;cursor:pointer}.examtest_content_list_des_checked{background:#dff1e5}.hidden{display:none}.pc_examtest_title{font-size:24px;margin:18px 0}.pc_examtest_content_question_showanswer{margin-left:12px;color:#3268ac}</style><body onselectstart="return false" oncontextmenu="return false"><h1>${practice ? '练习页面' : '任意网址的题库搜索'}</h1><p id="copy-target">广西华谊新材料有限公司涉及几种高危工艺</p>${practice ? practiceHTML() : '<p>网页没有练习题目,仍可使用岗位题库搜索与复制开关。</p>'}<script>
window.optionClicks=[];window.parseClicks=0;window.submitClicks=0;
document.addEventListener('click',function(event){const n=event.target.closest('.pc_examtest_content_list_des');if(n){const box=n.closest('.pc_examtest_content');if(box.dataset.showanswer==='true')return;const label=n.querySelector('.examtest_content_list_des_optionsort').textContent;window.optionClicks.push(box.dataset.number+':'+label);if(box.dataset.kind==='多选')n.classList.toggle('examtest_content_list_des_checked');else{box.querySelectorAll('.pc_examtest_content_list_des').forEach(x=>x.classList.remove('examtest_content_list_des_checked'));n.classList.add('examtest_content_list_des_checked');box.dataset.showanswer='true';box.querySelector('.pc_examtest_content_question_showanswer').remove();box.querySelectorAll('.pc_examtest_content_list_child1').forEach(x=>x.className='pc_examtest_content_list_child2');}}if(event.target.closest('.pc_examtest_content_question_showanswer'))window.parseClicks++;if(event.target.id==='submit')window.submitClicks++;});
</script></body>`;
}
(async () => {
const memory = new Map([['yianxue.assistant.setting.v2.autoExport', false]]);
let clipboard = '';
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
res.end(documentHTML(req.url.includes('exercise') || req.url.includes('flag=test')));
});
await new Promise(resolve => server.listen(0, resolve));
const port = server.address().port;
let browser;
try {
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const context = await browser.newContext({ viewport: { width: 1366, height: 900 }, acceptDownloads: true });
await context.exposeBinding('__gmRead', (source, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__gmWrite', (source, key, value) => { memory.set(key, structuredClone(value)); });
await context.exposeBinding('__gmCopy', (source, text) => { clipboard = text; });
// API 仅作为沙箱全局变量存在,刻意不放到 window 上。
const sandboxScript = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);'
+ 'const GM_setValue=(key,value)=>window.__gmWrite(key,value);const GM_setClipboard=text=>window.__gmCopy(text);\n'
+ script + '\n})();';
const errors = [];
function capture(page) { page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); }); }
const page = await context.newPage(); capture(page);
const startURL = `http://127.0.0.1:${port}/completely-unrelated-path`;
await page.goto(startURL); await page.addScriptTag({ content: sandboxScript });
await page.evaluate(() => YianAssistant.ready);
assert.equal(page.url(), startURL); assert.equal(await page.title(), '普通网页验证');
assert(await page.getByRole('heading', { name: '任意网址的题库搜索' }).isVisible());
await page.getByRole('button', { name: '展开题库助手' }).click();
assert(await page.getByRole('switch', { name: '自动选择答案' }).isDisabled());
assert.equal(await page.evaluate(() => typeof window.GM_getValue), 'undefined');
console.log('PASS 任意路径显示搜索面板,非练习页禁止自动选择,沙箱API兼容');
const input = await bankInput();
await page.locator('#yian-question-assistant input[type=file]').setInputFiles(input.file);
await page.waitForFunction(total => YianAssistant.getSnapshot().total === total, input.total);
assert.deepEqual(await page.evaluate(() => YianAssistant.getSnapshot().counts), input.counts);
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().storage), 'userscript');
await page.getByRole('radio', { name: '主操', exact: true }).check();
await page.getByRole('textbox', { name: '搜索题目' }).fill('工程控制措施');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert(await page.locator('#yian-question-assistant .result .conflict').count() > 0);
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('主操'));
console.log('PASS ' + (input.real ? '实际ZIP导入5520条' : '合成ZIP导入90条') + '、岗位数量与冲突来源显示');
// 未点击搜索按钮的新输入,也应在换岗位时立即检索。
const query = '广西华谊新材料有限公司涉及几种高危工艺';
await page.getByRole('textbox', { name: '搜索题目' }).fill(query);
await page.getByRole('radio', { name: '副操', exact: true }).check();
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('副操'));
assert(await page.locator('#yian-question-assistant .result').count() > 0);
assert((await page.locator('#yian-question-assistant .sources').first().innerText()).includes('副操'));
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
assert(clipboard.includes('正确答案:')); assert(clipboard.includes('高危工艺'));
await page.getByRole('radio', { name: '班长', exact: true }).check();
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('班长'));
await page.getByRole('radio', { name: '主操', exact: true }).check();
await page.getByRole('textbox', { name: '搜索题目' }).fill('作业');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert.equal(await page.locator('#yian-question-assistant .result').count(), 20);
await page.getByRole('button', { name: '下一页', exact: true }).click();
assert((await page.locator('#yian-question-assistant .pagination').innerText()).includes('21–'));
assert(await page.locator('#yian-question-assistant .result').count() > 0);
console.log('PASS 三岗位隔离、换岗位保留当前关键词、结果复制与20条分页');
const restrictions = () => page.evaluate(() => ({ select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), blocked: !document.body.dispatchEvent(new Event('selectstart', { bubbles: true, cancelable: true })), style: getComputedStyle(document.querySelector('#copy-target')).userSelect }));
assert.deepEqual(await restrictions(), { select: null, context: null, blocked: false, style: 'text' });
await page.getByRole('switch', { name: '允许复制文字' }).uncheck();
assert.deepEqual(await restrictions(), { select: 'return false', context: 'return false', blocked: true, style: 'none' });
await page.getByRole('switch', { name: '允许复制文字' }).check();
await page.evaluate(() => { const range = document.createRange(); range.selectNodeContents(document.getElementById('copy-target')); const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range); });
await page.getByRole('button', { name: '用选中文字搜索', exact: true }).click();
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
await page.locator('#yian-question-assistant .body').evaluate(node => { node.scrollTop = 0; });
await page.screenshot({ path: path.join(qaDir, 'search-desktop.png') });
console.log('PASS 允许复制开启/关闭恢复原限制,正文选中文字搜索');
await page.locator('#yian-question-assistant input[type=file]').setInputFiles({ name: 'invalid.csv', mimeType: 'text/csv', buffer: Buffer.from('题目,参考答案\n坏数据,A') });
await page.waitForFunction(() => document.querySelector('#yian-question-assistant').shadowRoot.querySelector('.notice').textContent.includes('导入失败'));
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), input.total);
console.log('PASS 无效导入提示与原题库保留');
const second = await context.newPage(); capture(second);
const secondURL = `http://localhost:${port}/different/application/route?flag=exercise`;
await second.goto(secondURL); await second.addScriptTag({ content: sandboxScript }); await second.evaluate(() => YianAssistant.ready);
await second.waitForFunction(() => YianAssistant.getState().pageReady);
assert.equal(await second.evaluate(() => YianAssistant.getSnapshot().total), input.total);
assert.equal(await second.evaluate(() => YianAssistant.getState().role), '主操');
assert.equal(await second.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForFunction(() => YianAssistant.getState().metrics.selected === 3);
assert.deepEqual(await second.evaluate(() => window.optionClicks), ['1:B', '2:A', '2:C', '2:D', '3:B']);
assert.equal(await second.evaluate(() => YianAssistant.getState().metrics.pending), 1);
assert.equal(await second.evaluate(() => window.parseClicks + window.submitClicks), 0);
await second.waitForTimeout(1500); assert.equal(await second.evaluate(() => window.optionClicks.length), 5);
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForTimeout(500); assert.equal(await second.evaluate(() => window.optionClicks.length), 5);
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
console.log('PASS 更换域名/路径后题库岗位共用,实际选项点击、换序、多选差集与无重复');
await second.evaluate(html => { history.pushState({}, '', '/another/random-route?flag=exercise'); const doc = new DOMParser().parseFromString(html, 'text/html'); document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest')); window.optionClicks = []; }, practiceHTML());
await second.waitForFunction(() => YianAssistant.getContext().ready);
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForFunction(() => window.optionClicks.length > 0);
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
const stoppedAt = await second.evaluate(() => window.optionClicks.length);
await second.waitForTimeout(700); assert.equal(await second.evaluate(() => window.optionClicks.length), stoppedAt);
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForFunction(() => { const m = YianAssistant.getState().metrics; return m.selected + m.locked === 3; });
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
const modeURL = second.url().replace('flag=exercise', 'flag=test');
await second.evaluate(url => history.pushState({}, '', url), modeURL);
await second.waitForTimeout(800); assert(await second.getByRole('switch', { name: '自动选择答案' }).isDisabled());
assert.equal(await second.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
console.log('PASS 面板关闭立即停止后续点击、重新开启续作、明确考试模式禁用');
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
await page.locator('#yian-question-assistant .body').evaluate(node => { node.scrollTop = 0; });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
await page.screenshot({ path: path.join(qaDir, 'search-mobile.png') });
await page.setViewportSize({ width: 1366, height: 900 });
await page.locator('#yian-question-assistant .body').evaluate(node => { node.scrollTop = 0; });
await page.locator('#yian-question-assistant').screenshot({ path: path.join(qaDir, 'assistant-panel.png') });
assert.deepEqual(errors, []);
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; });