/* 指定主站范围、完整面板与同站跨路径存储验证;所有主站请求均由本机模拟。 */
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 `
广西华谊新材料有限公司涉及几种高危工艺
${practice ? practiceHTML() : '网页没有练习题目,仍可使用岗位题库搜索与复制开关。
'}`; } (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); 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'); assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false); 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 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 = origin + '/clientcn/exam/nested/fixture/questions?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.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), '主操'); 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; });