Files
yianxue-assistant/tests/assistant.cjs

365 lines
31 KiB
JavaScript
Raw Permalink 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.
/* 指定主站范围、完整面板与同站跨路径存储验证;所有主站请求均由本机模拟。 */
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 { pathToFileURL } = require('node:url');
const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..');
const script = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
assert.deepEqual(Array.from(script.matchAll(/^\/\/ @match\s+(.+)$/gm), match => match[1].trim()), ['http://10.119.67.7/clientcn/*']);
assert(script.includes('// @name 谊安学助手 2.0'));
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 function activate(page, key) {
const expand = page.getByRole('button', { name: '展开题库助手', exact: true });
if (await expand.isVisible()) await expand.click();
await page.getByTestId('tab-' + key).click();
}
(async () => {
const memory = new Map([['yianxue.assistant.setting.v2.autoExport', true], ['yianxue.assistant.setting.v2.exportEnabled', true]]);
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, '127.0.0.1', resolve));
const port = server.address().port;
const localOrigin = 'http://127.0.0.1:' + 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({ viewport: { width: 1366, height: 900 }, acceptDownloads: true });
const externalRequests = [];
const rejectedURLs = [
'https://10.119.67.7/clientcn/main',
'http://10.119.67.8/clientcn/main',
'http://localhost/clientcn/main',
'http://10.119.67.7:8080/clientcn/main',
origin + '/outside/main',
origin + '/clientcn-other/main',
origin + '/clientcn'
];
const rejectedNavigation = new Set(rejectedURLs);
await context.route('**/*', async route => {
const request = route.request(), url = new URL(request.url());
// Even the exact business origin is never continued onto the network.
if (url.origin === origin) {
const response = await route.fetch({ url: localOrigin + url.pathname + url.search });
return route.fulfill({ response });
}
// Deliberate negative navigation fixtures are fulfilled without reaching those hosts.
if (request.isNavigationRequest() && rejectedNavigation.has(url.href)) {
return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: documentHTML(false) });
}
if (url.origin === localOrigin || url.protocol === 'file:' && url.href.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue();
externalRequests.push(url.href); return route.abort();
});
let delayedPositionPage = null;
let delayedPositionRead = null;
let signalPositionRead = null;
const positionKey = 'yianxue.assistant.setting.v2.panelPosition';
await context.exposeBinding('__gmRead', (source, key, fallback) => {
const captured = memory.has(key) ? structuredClone(memory.get(key)) : fallback;
// Hold only the first position read from one dedicated page; subsequent reads
// and all other pages/keys remain immediate, including verification of new writes.
if (source.page === delayedPositionPage && key === positionKey && !delayedPositionRead) {
return new Promise(resolve => {
delayedPositionRead = { captured, release: () => resolve(captured) };
if (signalPositionRead) signalPositionRead();
});
}
return captured;
});
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 = origin + '/clientcn/main?from=assistant-test';
await page.goto(startURL); await page.addScriptTag({ content: sandboxScript });
await page.evaluate(() => YianAssistant.ready);
await page.evaluate(() => YianAssistant.setLearningSetting('answerInterval', 0));
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.locator('#yian-question-assistant .brand').textContent() === '谊安学助手 2.0');
await activate(page, 'settings');
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
await activate(page, 'exam');
assert(await page.getByRole('switch', { name: '自动选择答案' }).isDisabled());
assert.equal(await page.evaluate(() => typeof window.GM_getValue), 'undefined');
console.log('PASS 主站 /clientcn/main 查询参数下显示搜索面板,非练习页禁止自动选择,沙箱 API 兼容');
const input = await bankInput();
await activate(page, 'bank');
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), Object.assign({ 未标明: 0 }, input.counts));
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().storage), 'userscript');
assert.equal(await page.getByRole('radio').count(), 0, '统一题库已取消岗位选择');
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 activate(page, 'console'); await activate(page, 'bank');
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('统一题库'));
assert(await page.locator('#yian-question-assistant .result').count() > 0);
const unifiedSources = await page.locator('#yian-question-assistant .sources').first().innerText();
for (const source of ['主操', '班长', '副操']) assert(unifiedSources.includes(source), '统一结果应保留来源:' + source);
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
assert(clipboard.includes('正确答案:')); assert(clipboard.includes('高危工艺'));
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 activate(page, 'settings');
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 activate(page, 'bank');
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 = origin + '/clientcn/exam/nested/fixture/questions?flag=exercise';
await second.goto(secondURL); await second.addScriptTag({ content: sandboxScript }); await second.evaluate(() => YianAssistant.ready);
await second.evaluate(() => YianAssistant.setLearningSetting('answerInterval', 0));
await second.waitForFunction(() => YianAssistant.getState().pageReady);
await activate(second, 'settings');
assert.equal(await second.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false, '旧版导出偏好不能开启新版开关');
assert.equal(await second.evaluate(() => YianAssistant.getSnapshot().total), input.total);
assert.equal(await second.evaluate(() => YianAssistant.getState().role), '');
await activate(second, 'exam');
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 同站不同 /clientcn 路径统一题库共用,实际选项点击、换序、多选差集与无重复');
await second.evaluate(html => { history.pushState({}, '', '/clientcn/exam/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') });
// Drag the real header with browser input; persist the released coordinates through GM.
const beforeDrag = await page.locator('#yian-question-assistant').boundingBox();
const brand = await page.locator('#yian-question-assistant .brand').boundingBox();
assert(beforeDrag && brand);
await page.mouse.move(brand.x + brand.width / 2, brand.y + brand.height / 2);
await page.mouse.down();
await page.mouse.move(brand.x + brand.width / 2 - 260, brand.y + brand.height / 2 + 35, { steps: 12 });
await page.mouse.up();
const draggedPosition = await page.evaluate(() => YianAssistant.getState().panelPosition);
assert(draggedPosition && Number.isFinite(draggedPosition.left) && Number.isFinite(draggedPosition.top));
assert(draggedPosition.left < beforeDrag.x - 100, '真实拖拽应改变面板位置');
assert(await page.locator('#yian-question-assistant .body').isVisible(), '拖动标题不能误触收起');
await page.waitForFunction(async position => JSON.stringify(await YianBankStore.getSetting('panelPosition', null)) === JSON.stringify(position), draggedPosition);
await page.reload(); await page.addScriptTag({ content: sandboxScript }); await page.evaluate(() => YianAssistant.ready);
assert.deepEqual(await page.evaluate(() => YianAssistant.getState().panelPosition), draggedPosition, '刷新主站后应恢复 GM 保存位置');
const restoredPosition = await page.locator('#yian-question-assistant').boundingBox();
assert(Math.abs(restoredPosition.x - draggedPosition.left) < 2 && Math.abs(restoredPosition.y - draggedPosition.top) < 2);
await page.getByRole('button', { name: '展开题库助手' }).click();
assert(await page.locator('#yian-question-assistant .body').isVisible(), '恢复位置后仍可展开面板');
console.log('PASS 标题真实鼠标拖拽、不误折叠、GM 保存坐标与刷新主站后的面板位置恢复');
// A late initial settings response cannot overwrite a new position chosen by the user.
const positionReadStarted = new Promise(resolve => { signalPositionRead = resolve; });
memory.set(positionKey, { left: 100, top: 100 });
delayedPositionPage = await context.newPage(); capture(delayedPositionPage);
await delayedPositionPage.goto(origin + '/clientcn/main?scope=position-read-race');
await delayedPositionPage.addScriptTag({ content: sandboxScript });
await positionReadStarted;
assert.deepEqual(delayedPositionRead.captured, { left: 100, top: 100 }, '迟到读取必须返回读取时捕获的旧坐标');
await delayedPositionPage.evaluate(() => { window.__qaReadyComplete = false; YianAssistant.ready.then(() => { window.__qaReadyComplete = true; }); });
assert.equal(await delayedPositionPage.evaluate(() => window.__qaReadyComplete), false);
const raceHost = await delayedPositionPage.locator('#yian-question-assistant').boundingBox();
const raceBrand = await delayedPositionPage.locator('#yian-question-assistant .brand').boundingBox();
assert(raceHost && raceBrand);
assert(await delayedPositionPage.getByRole('button', { name: '展开题库助手' }).isVisible(), '加载期间仍为可拖动的收起标题');
const grabOffset = { x: raceBrand.x + raceBrand.width / 2 - raceHost.x, y: raceBrand.y + raceBrand.height / 2 - raceHost.y };
await delayedPositionPage.mouse.move(raceHost.x + grabOffset.x, raceHost.y + grabOffset.y);
await delayedPositionPage.mouse.down();
await delayedPositionPage.mouse.move(220 + grabOffset.x, 60 + grabOffset.y, { steps: 14 });
await delayedPositionPage.mouse.up();
const newPosition = await delayedPositionPage.evaluate(() => YianAssistant.getState().panelPosition);
assert.deepEqual(newPosition, { left: 220, top: 60 }, '加载完成前的真实拖动应记录新位置');
await delayedPositionPage.waitForFunction(async position => JSON.stringify(await YianBankStore.getSetting('panelPosition', null)) === JSON.stringify(position), newPosition);
assert.deepEqual(memory.get(positionKey), newPosition, '释放旧读取前,新位置已写入 GM');
assert.equal(await delayedPositionPage.evaluate(() => window.__qaReadyComplete), false, '拖动和写入必须发生在 ready 等待期间');
delayedPositionRead.release();
await delayedPositionPage.evaluate(() => YianAssistant.ready);
assert.equal(await delayedPositionPage.evaluate(() => window.__qaReadyComplete), true);
assert.deepEqual(await delayedPositionPage.evaluate(() => YianAssistant.getState().panelPosition), newPosition, '迟到旧设置不得覆盖当前用户坐标');
assert.deepEqual(memory.get(positionKey), newPosition, 'GM 中仍应保留新坐标');
const settledRacePosition = await delayedPositionPage.locator('#yian-question-assistant').boundingBox();
assert(Math.abs(settledRacePosition.x - newPosition.left) < 2 && Math.abs(settledRacePosition.y - newPosition.top) < 2, 'ready 后的实际面板应停留在用户新位置');
await delayedPositionPage.close(); delayedPositionPage = null; signalPositionRead = null;
console.log('PASS 初始 GM 位置读取延迟期间真实拖动,迟到旧坐标不覆盖新配置、GM 写值或实际面板位置');
// A running SPA session must suspend its UI and copy controller immediately on leaving scope.
await second.evaluate(html => {
history.replaceState({}, '', '/clientcn/exam/scope-start/questions?flag=exercise');
const doc = new DOMParser().parseFromString(html, 'text/html');
document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest'));
window.optionClicks = [];
dispatchEvent(new PopStateEvent('popstate'));
}, practiceHTML());
await second.waitForFunction(() => YianAssistant.getContext().ready);
const leftScope = await second.evaluate(() => {
YianAssistant.setCopy(true);
const enabled = YianAssistant.setAutoAnswer(true);
YianAssistant.setExportEnabled(true);
const before = YianAssistant.getState();
history.pushState({}, '', '/outside/spa-scope?flag=exercise');
dispatchEvent(new PopStateEvent('popstate'));
return { enabled, before, after: YianAssistant.getState() };
});
assert.equal(leftScope.enabled, true);
assert.equal(leftScope.before.autoAnswer, true); assert.equal(leftScope.before.exportEnabled, true);
await second.waitForFunction(() => getComputedStyle(document.querySelector('#yian-question-assistant')).display === 'none');
assert.equal(leftScope.after.autoAnswer, false); assert.equal(leftScope.after.exportEnabled, false);
assert.equal(await second.evaluate(() => YianAssistant.getContext().ready), false);
assert.deepEqual(await second.evaluate(() => ({ select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), style: getComputedStyle(document.querySelector('#copy-target')).userSelect })),
{ select: 'return false', context: 'return false', style: 'none' }, '离开范围应恢复网页复制限制');
assert.equal(await second.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
assert((await second.evaluate(() => { try { YianAssistant.exportCurrent('csv'); return ''; } catch (error) { return error.message; } })).includes('范围'));
await second.waitForTimeout(400);
assert.equal(await second.evaluate(() => window.optionClicks.length), 0, '离开范围后不能产生后续选项点击');
await second.evaluate(() => { history.pushState({}, '', '/clientcn/exam/scope-start/questions?flag=exercise'); dispatchEvent(new PopStateEvent('popstate')); });
await second.waitForFunction(() => YianAssistant.getContext().ready);
assert(await second.locator('#yian-question-assistant').isVisible());
assert.equal(await second.evaluate(() => YianAssistant.getState().autoAnswer), false);
assert.equal(await second.evaluate(() => YianAssistant.getState().exportEnabled), false);
assert.deepEqual(await second.evaluate(() => ({ select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), style: getComputedStyle(document.querySelector('#copy-target')).userSelect })),
{ select: null, context: null, style: 'text' }, '返回主站范围应恢复当前复制开关');
assert.equal(await second.evaluate(() => window.optionClicks.length), 0);
console.log('PASS SPA 离开 /clientcn/ 立即隐藏面板、停止答题/导出并恢复复制限制;返回后恢复面板与复制、自动开关保持关闭');
// The outer bundle guard is tested even when code is injected manually, outside metadata scope.
for (const allowedURL of [origin + '/clientcn/main', origin + '/clientcn/main/deep/path?scope=query', 'http://10.119.67.7:80/clientcn/main?scope=default-port']) {
const allowed = await context.newPage(); capture(allowed);
await allowed.goto(allowedURL); await allowed.addScriptTag({ content: sandboxScript });
await allowed.evaluate(() => YianAssistant.ready);
assert.equal(await allowed.locator('#yian-question-assistant').count(), 1);
assert.equal(await allowed.evaluate(() => typeof JSZip), 'function');
await allowed.close();
}
const localPath = path.join(qaDir, 'scope-rejected.html');
fs.writeFileSync(localPath, documentHTML(false));
for (const rejectedURL of rejectedURLs.concat(pathToFileURL(localPath).href)) {
const rejected = await context.newPage(); capture(rejected);
await rejected.goto(rejectedURL);
const inspect = () => rejected.evaluate(() => ({
select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'),
style: document.body.getAttribute('style'), userSelect: getComputedStyle(document.querySelector('#copy-target')).userSelect,
blocked: !document.body.dispatchEvent(new Event('selectstart', { bubbles: true, cancelable: true })),
body: document.body.innerHTML
}));
const before = await inspect();
await rejected.addScriptTag({ content: sandboxScript });
assert.equal(await rejected.locator('#yian-question-assistant').count(), 0, rejectedURL);
assert.deepEqual(await rejected.evaluate(() => ['JSZip', 'QuestionBankCore', 'YianBankStore', 'YianAnswerMatcher', 'YianCopyController', 'YianAssistantPanel', 'YianAnswerRunner', 'YianAssistant', 'YianQuestionBankExporter'].map(key => [key, typeof window[key]])),
['JSZip', 'QuestionBankCore', 'YianBankStore', 'YianAnswerMatcher', 'YianCopyController', 'YianAssistantPanel', 'YianAnswerRunner', 'YianAssistant', 'YianQuestionBankExporter'].map(key => [key, 'undefined']), rejectedURL + ' 整个 bundle 必须不注入');
assert.deepEqual(await inspect(), before, rejectedURL + ' 不得改变复制限制或正文');
await rejected.close();
}
assert.deepEqual(externalRequests, [], '所有非测试目标请求应被阻断;业务 origin 只允许模拟响应');
assert.deepEqual(errors, []);
console.log('PASS HTTP 主站 main / 子目录 / 查询参数有效;HTTPS、其他 host、非80端口、范围外路径、相似目录和 file 均无面板/API/JSZip且不改变复制限制');
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; });