feat: add draggable panel and restrict assistant to site directory

This commit is contained in:
薇星幻海
2026-10-12 14:19:31 +08:00
parent 3d7a1ffed0
commit 20abb33740
12 changed files with 968 additions and 77 deletions
+9 -5
View File
@@ -6,12 +6,16 @@
1. 在 Chrome / Edge 中安装并启用油猴(Tampermonkey)。
2. 新建脚本,将 `谊安学助手 2.0.user.js` 的全部内容粘贴进去,保存。脚本显示名称为“谊安学助手 2.0”。升级时替换原脚本全文,不要同时启用新旧两份脚本。
3. 刷新网页,点击右下角“题库助手”入口展开面板。
3. 刷新谊安学网页,点击右下角“题库助手”入口展开面板。拖动顶部标题栏可移动面板,位置会保存。
4. 选择“主操 / 班长 / 副操”,点击“导入题库”,选择 ZIP、CSV 或 JSON 文件。
脚本使用 `@match *://*/*` 和 `@match file:///*`,不固定域名、IP、端口或网址路径,换地址后无需修改脚本。普通网页均显示可折叠入口;`chrome://` 等浏览器限制页面无法注入。访问本地 HTML 时,需在扩展设置中开启“允许访问文件网址”。
脚本仅在 `http://10.119.67.7/clientcn/*` 生效,覆盖 `/clientcn/main`、它的下级路径,以及同目录的 `/clientcn/exam/` 练习和考试页面。其他域名、HTTPS、其他端口、目录外页面及本地文件不显示面板,也不解除复制限制。除油猴 `@match` 外,脚本启动时再次核对网址。
**网址通用不等于所有平台都能自动答题。** 自动选择适配提供的谊安学桌面练习页,以及明确为 `flag=test`、完整加载并且仍可作答的桌面考试页。未知模式、移动端虚拟翻页试卷、已提交或计时结束的考试不会自动选择。`file:` 保存网页只用于搜索、复制和核对;原练习页可导出,不执行自动选择。
同一网页内切换路由离开 `/clientcn/` 时,脚本停止自动选择和导出、隐藏面板并恢复复制限制;返回范围内后重新显示面板,自动选择与导出保持关闭。
标题栏支持鼠标和触屏拖动,展开或收起都可移动;拖拽不会触发展开/收起。按钮、搜索输入和正文滚动保持原操作。刷新或切换页面会恢复保存位置;窗口缩小、面板展开或内容变高时自动调整到可见区域。
自动选择适配提供的谊安学桌面练习页,以及明确为 `flag=test`、完整加载并且仍可作答的桌面考试页。未知模式、移动端虚拟翻页试卷、已提交或计时结束的考试不会自动选择。下载的 HTML 可使用独立的“离线导出题库”工具解析。
## 导入题库与搜索答案
@@ -28,7 +32,7 @@
- ZIP 内按“主操 / 班长 / 副操”目录自动归类;成功导入后全量替换已有题库。提供的 `题库.zip` 已核对为 28 个 CSV、5,520 条记录:主操 1,620、班长 2,340、副操 1,560。
- 单个或多个 CSV / JSON 建议先选择岗位;文件缺少岗位信息时,使用当前选择的岗位。导入会更新相同岗位、课程和来源的数据,保留其他岗位及其他来源。
- 所有文件先解析和验证,再保存。题干、选项或参考答案无效时,会提示文件及题目位置,原题库保留。
- 油猴存储在同一浏览器中跨网址共享题库及岗位设置,刷新后无需重新导入。无油猴存储 API 时,回退为当前网站的本地存储,面板会明确提示;此模式不能跨网址共享。
- 油猴存储在同一浏览器中共享题库、岗位、复制设置及面板位置,站点内切换页面或刷新后无需重新导入。无油猴存储 API 时,回退为当前网站的本地存储,面板会明确提示。
搜索时先选择岗位,可再按课程筛选。输入题干、选项文字或关键词后点击“搜索”;多个关键词用空格分开,结果须同时包含所有关键词。也可选中网页题目文字后点击“用选中文字搜索”。切换岗位会保留当前搜索词并重新查询。
@@ -75,7 +79,7 @@
## 开发与验证
`question-bank-core.js` 负责网页提取和导出;`bank-store.js`、`answer-matcher.js` 负责本地题库和匹配搜索;`assistant-panel.js`、`copy-controller.js`、`answer-runner.js` 分别负责面板、复制开关和真实选项点击;`practice-exporter.js` 负责页面识别及功能协调。发布脚本已构建,安装时只需一个 `.user.js` 文件。
`site-scope.js` 负责站点范围;`question-bank-core.js` 负责网页提取和导出;`bank-store.js`、`answer-matcher.js` 负责本地题库和匹配搜索;`assistant-panel.js`、`copy-controller.js`、`answer-runner.js` 分别负责面板拖拽、复制开关和真实选项点击;`practice-exporter.js` 负责页面识别及功能协调。发布脚本已构建,安装时只需一个 `.user.js` 文件。
修改源码后重新构建:
+176 -3
View File
File diff suppressed because one or more lines are too long
+8 -5
View File
@@ -3,10 +3,9 @@ import { readFileSync, writeFileSync } from 'node:fs';
const metadata = `// ==UserScript==
// @name 谊安学助手 2.0
// @namespace yianxue-question-bank
// @version 2.0.2
// @description 不绑定网址的题库助手:岗位搜索、答案全文核对、练习及桌面考试自动选择。
// @match *://*/*
// @match file:///*
// @version 2.0.3
// @description 谊安学站点助手:可拖动面板、岗位搜索、答案全文核对及自动选择。
// @match http://10.119.67.7/clientcn/*
// @run-at document-idle
// @grant GM_getValue
// @grant GM_setValue
@@ -25,5 +24,9 @@ const zipWrapper = '(function () { const module = { exports: {} }; const exports
const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js',
'copy-controller.js', 'assistant-panel.js', 'answer-runner.js', 'practice-exporter.js'];
const source = modules.map(name => readFileSync(new URL('./' + name, import.meta.url), 'utf8')).join('\n');
writeFileSync(new URL('./谊安学助手 2.0.user.js', import.meta.url), metadata + '\n' + notices + zipWrapper + source, 'utf8');
const scope = readFileSync(new URL('./site-scope.js', import.meta.url), 'utf8');
// Also enforce the boundary when a manager injects manually or ignores metadata.
const bundle = '(function () {\n' + scope + '\nif (!YianSiteScope.isAllowedURL(location.href)) return;\n'
+ zipWrapper + source + '\n})();\n';
writeFileSync(new URL('./谊安学助手 2.0.user.js', import.meta.url), metadata + '\n' + notices + bundle, 'utf8');
console.log('已生成 谊安学助手 2.0.user.js');
+4 -3
View File
@@ -1,15 +1,16 @@
{
"name": "yianxue-question-bank-exporter",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"description": "不绑定网址的谊安学题库助手:岗位搜索、答案全文核对、练习及桌面考试自动选择",
"description": "谊安学站点助手:可拖动面板、岗位搜索、答案全文核对及自动选择",
"scripts": {
"build": "node build-userscript.mjs",
"merge:banks": "node scripts/merge-question-banks.cjs",
"pretest": "node build-userscript.mjs",
"test": "node tests/run.cjs",
"test:assistant": "node tests/assistant.cjs",
"test:exam": "node tests/exam-assistant.cjs"
"test:exam": "node tests/exam-assistant.cjs",
"test:drag": "node tests/panel-drag.cjs"
},
"devDependencies": {
"playwright": "1.62.1"
+33 -7
View File
@@ -4,15 +4,17 @@
const core = window.QuestionBankCore;
const store = window.YianBankStore;
const matcher = window.YianAnswerMatcher;
const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '' };
const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '', panelPosition: null };
let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' };
let loading = true, disposed = false, page = null, previous = null;
let loading = true, disposed = false, scopedOut = false, page = null, previous = null;
let searchQuery = '', runKey = '', debounceTimer;
const exported = new Set();
const settleMs = 1200;
const copy = window.YianCopyController.create();
const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 });
let metrics = emptyMetrics();
let positionRevision = 0;
const inScope = () => YianSiteScope.isAllowedURL(location.href);
function visible(node) {
if (!node || node.closest('[hidden],.hidden')) return false;
@@ -59,7 +61,7 @@
const route = routeInfo();
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
const signature = route.root ? structureSignature(bank) : '';
return { ready: !!page && page.ready && !loading && page.key === route.key
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
&& page.root === route.root && page.signature === signature,
practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title,
sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root,
@@ -80,7 +82,7 @@
}
function refreshPanel(extra) {
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
exportEnabled: config.exportEnabled, role: config.role, course: config.course, courses: courses(),
exportEnabled: config.exportEnabled, position: config.panelPosition, role: config.role, course: config.course, courses: courses(),
bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
}
function performSearch(query = searchQuery, options = {}) {
@@ -111,7 +113,7 @@
refreshPanel(); tick(); return config.autoAnswer;
}
function setCopy(enabled) {
config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy); refreshPanel(); saveSetting('allowCopy', config.allowCopy);
config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); saveSetting('allowCopy', config.allowCopy);
}
async function importFiles(files) {
runner.stop('正在更新题库'); loading = true; runKey = ''; refreshPanel(); panel.setNotice('正在解析题库,请稍候…');
@@ -125,6 +127,7 @@
} finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); }
}
function exportCurrent(format = 'csv') {
if (!inScope()) throw new Error('当前页面不在谊安学脚本生效范围内');
if (!config.exportEnabled) throw new Error('请先开启“导出题库”开关');
const route = routeInfo();
if (!route.practice) throw new Error('请先进入支持的练习页面');
@@ -144,6 +147,7 @@
}
const panel = window.YianAssistantPanel.create({
onAutoChange: setAutoAnswer, onCopyChange: setCopy,
onPositionChange: position => { positionRevision++; config.panelPosition = position; saveSetting('panelPosition', position); },
onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); },
onCourseChange: course => { config.course = course; performSearch(); },
onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch,
@@ -154,6 +158,21 @@
function tick() {
if (disposed) return;
if (!inScope()) {
if (!scopedOut) {
scopedOut = true;
config.autoAnswer = false; config.exportEnabled = false; runKey = '';
runner.stop('页面已离开脚本生效范围'); copy.setEnabled(false);
panel.host.style.setProperty('display', 'none', 'important');
previous = page; page = null; metrics = emptyMetrics();
}
return;
}
if (scopedOut) {
scopedOut = false;
panel.host.style.setProperty('display', 'block', 'important');
copy.setEnabled(config.allowCopy);
}
const route = routeInfo();
if (!route.root) {
if (runner.running) runner.stop('当前页面未识别到练习题目');
@@ -225,12 +244,19 @@
runner.destroy(); copy.destroy(); panel.destroy();
}
const ready = (async () => {
const positionRevisionAtRead = positionRevision;
try {
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true)]);
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : '';
config.allowCopy = values[2] !== false;
const position = values[3];
// A drag during asynchronous storage loading wins over the older saved position.
if (positionRevision === positionRevisionAtRead) {
config.panelPosition = position && typeof position.left === 'number' && Number.isFinite(position.left)
&& typeof position.top === 'number' && Number.isFinite(position.top) ? { left: position.left, top: position.top } : null;
}
} catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); }
loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot;
loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); tick(); return snapshot;
})();
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext,
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }),
+14
View File
@@ -0,0 +1,14 @@
/* Shared URL boundary for userscript injection and same-page navigation. */
const YianSiteScope = (function () {
'use strict';
const origin = 'http://10.119.67.7';
const basePath = '/clientcn/';
function isAllowedURL(value) {
try {
const url = new URL(String(value));
return url.origin === origin && url.pathname.startsWith(basePath);
} catch (_) { return false; }
}
return Object.freeze({ origin, basePath, isAllowedURL });
})();
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) module.exports = YianSiteScope;
+180 -13
View File
@@ -1,14 +1,14 @@
/* 完整面板与跨网址存储验证;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 { 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(script.includes('// @match *://*/*'));
assert(!/\/\/ @match\s+https?:\/\/10\./.test(script));
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 });
@@ -42,7 +42,7 @@ function practiceHTML() {
`<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>
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>`;
@@ -55,13 +55,55 @@ function documentHTML(practice) {
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));
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 });
await context.exposeBinding('__gmRead', (source, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
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 上。
@@ -71,17 +113,17 @@ function documentHTML(practice) {
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`;
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());
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 任意路径显示搜索面板,非练习页禁止自动选择,沙箱API兼容');
console.log('PASS 主站 /clientcn/main 查询参数下显示搜索面板,非练习页禁止自动选择,沙箱 API 兼容');
const input = await bankInput();
await page.locator('#yian-question-assistant input[type=file]').setInputFiles(input.file);
@@ -134,7 +176,7 @@ function documentHTML(practice) {
console.log('PASS 无效导入提示与原题库保留');
const second = await context.newPage(); capture(second);
const secondURL = `http://localhost:${port}/different/application/route?flag=exercise`;
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, '旧版导出偏好不能开启新版开关');
@@ -151,9 +193,9 @@ function documentHTML(practice) {
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 更换域名/路径后题库岗位共用,实际选项点击、换序、多选差集与无重复');
console.log('PASS 同站不同 /clientcn 路径题库岗位共用,实际选项点击、换序、多选差集与无重复');
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.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);
@@ -177,7 +219,132 @@ function documentHTML(practice) {
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)); }
+28 -15
View File
@@ -12,7 +12,7 @@ const scriptPath = path.join(workspace, '谊安学助手 2.0.user.js');
const script = fs.readFileSync(scriptPath, 'utf8');
const coreScript = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
assert(script.includes('// @name 谊安学助手 2.0'));
assert(script.includes('// @match *://*/*'));
assert.deepEqual(Array.from(script.matchAll(/^\/\/ @match\s+(.+)$/gm), match => match[1].trim()), ['http://10.119.67.7/clientcn/*']);
const qaDir = process.env.YIAN_EXAM_QA_DIR || path.join(os.tmpdir(), 'yianxue-exam-qa');
fs.mkdirSync(qaDir, { recursive: true });
const roles = ['主操', '班长', '副操'];
@@ -110,11 +110,16 @@ function installExamModel() {
await context.exposeBinding('__gmRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__gmWrite', (_, key, value) => { memory.set(key, structuredClone(value)); });
const sandboxScript = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);const GM_setValue=(key,value)=>window.__gmWrite(key,value);\n' + script + '\n})();';
let origin = '';
await context.route('**/*', route => {
const url = route.request().url();
if (url.startsWith(origin + '/') && origin || url.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue();
externalRequests.push(url); return route.abort();
const origin = 'http://10.119.67.7';
let localOrigin = '';
await context.route('**/*', async route => {
const url = new URL(route.request().url());
if (url.origin === origin && localOrigin) {
const response = await route.fetch({ url: localOrigin + url.pathname + url.search });
return route.fulfill({ response });
}
if (localOrigin && url.origin === localOrigin || url.protocol === 'file:' && url.href.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue();
externalRequests.push(url.href); return route.abort();
});
const capture = page => {
page.on('pageerror', error => errors.push(error.message));
@@ -139,8 +144,8 @@ function installExamModel() {
const pageHTML = documentHTML(fixture);
server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(pageHTML); });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
origin = 'http://127.0.0.1:' + server.address().port;
const examURL = origin + '/unfixed/exam-path?flag=test&title=' + encodeURIComponent(fixture.title);
localOrigin = 'http://127.0.0.1:' + server.address().port;
const examURL = origin + '/clientcn/exam/nested/exam-path?flag=test&title=' + encodeURIComponent(fixture.title);
const page = await context.newPage(); capture(page);
async function open(page, url) {
await page.goto(url);
@@ -231,7 +236,7 @@ function installExamModel() {
YianAssistant.setAutoAnswer(false);
const root = document.createElement('app-examtest'); root.innerHTML = html;
document.querySelector('app-examtest').replaceWith(root);
history.replaceState({}, '', '/unfixed/case-' + sequence + '?flag=' + mode + '&title=' + encodeURIComponent(title));
history.replaceState({}, '', '/clientcn/exam/case-' + sequence + '?flag=' + mode + '&title=' + encodeURIComponent(title));
if (mode === 'exercise') {
root.querySelector('.pc_examtest_commit').textContent = '重新练习';
root.querySelectorAll('.pc_examtest_content_question p').forEach(p => { const button = document.createElement('span'); button.className = 'pc_examtest_content_question_showanswer'; button.textContent = '查看答案'; p.appendChild(button); });
@@ -305,17 +310,25 @@ function installExamModel() {
const filePath = path.join(qaDir, 'saved-exam.html');
fs.writeFileSync(filePath, '<!-- saved from url=(0000)' + examURL + ' -->\n' + pageHTML);
const saved = await context.newPage(); capture(saved);
await open(saved, pathToFileURL(filePath).href);
await saved.waitForFunction(() => YianAssistant.getState().pageReady);
assert.equal(await saved.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
assert.equal(await saved.evaluate(() => examModel.clicks.length), 0);
await saved.goto(pathToFileURL(filePath).href);
const inspectSaved = () => saved.evaluate(() => ({
body: document.body.innerHTML, select: document.body.getAttribute('onselectstart'),
context: document.body.getAttribute('oncontextmenu'), style: document.body.getAttribute('style'),
userSelect: getComputedStyle(document.body).userSelect,
selected: document.querySelectorAll('.examtest_content_list_des_checked').length
}));
const beforeSaved = await inspectSaved();
await saved.addScriptTag({ content: sandboxScript });
assert.equal(await saved.locator('#yian-question-assistant').count(), 0);
assert.deepEqual(await saved.evaluate(() => [typeof window.JSZip, typeof window.QuestionBankCore, typeof window.YianAssistant]), ['undefined', 'undefined', 'undefined']);
assert.deepEqual(await inspectSaved(), beforeSaved, 'file 保存考试不能注入或改变复制限制和选项');
await saved.close();
await reset('exercise');
const navigation = await page.evaluate(({ html, title }) => {
const enabled = YianAssistant.setAutoAnswer(true);
const root = document.createElement('app-examtest'); root.innerHTML = html;
document.querySelector('app-examtest').replaceWith(root);
history.pushState({}, '', '/unfixed/final-exam?flag=test&title=' + encodeURIComponent(title));
history.pushState({}, '', '/clientcn/exam/final-exam?flag=test&title=' + encodeURIComponent(title));
dispatchEvent(new PopStateEvent('popstate'));
return { enabled, afterNavigation: YianAssistant.getState().autoAnswer };
}, { html: fixture.html, title: fixture.title });
@@ -323,7 +336,7 @@ function installExamModel() {
assert.equal(navigation.afterNavigation, false);
await page.waitForTimeout(250);
assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
console.log('PASS 未知 flag、移动页面、file 保存考试拒绝自动选择;练习转考试恢复关闭');
console.log('PASS 未知 flag、移动页面拒绝自动选择;file 保存考试完全不注入;练习转考试恢复关闭');
assert.deepEqual(externalRequests, [], '测试不允许任何外部业务请求');
assert.deepEqual(downloads, [], '考试不得自动或手动发起题库下载');
+253
View File
@@ -0,0 +1,253 @@
/* Browser plugin not available. 独立面板拖动回归:本机 HTTP、真实 Chrome 鼠标及 CDP 触屏输入。 */
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 panelSource = fs.readFileSync(path.join(workspace, 'assistant-panel.js'), 'utf8');
const qaDir = process.env.YIAN_DRAG_QA_DIR || path.join(os.tmpdir(), 'yianxue-drag-qa');
fs.mkdirSync(qaDir, { recursive: true });
function near(actual, expected, message) { assert(Math.abs(actual - expected) <= 2, `${message}: ${actual} != ${expected}`); }
async function main() {
// URL 无练习路径,证明拖动功能由独立面板模块负责。
const server = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
response.end('<!doctype html><meta charset="utf-8"><title>助手拖动验证</title><style>body{margin:0;background:#edf2f8;color:#203451;font:17px system-ui}main{padding:40px}p{color:#65758c;max-width:650px}</style><main><h1>题库助手拖动验证</h1><p>拖动标题栏调整位置。面板内的岗位、搜索和开关保持正常操作。</p></main>');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let browser;
try {
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
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 origin = `http://127.0.0.1:${server.address().port}`;
await page.goto(origin + '/panel-only');
assert.equal(await page.title(), '助手拖动验证');
assert.equal(page.url(), origin + '/panel-only');
await page.addScriptTag({ content: panelSource });
await page.evaluate(() => {
window.qaCalls = { positions: [], auto: [], copy: [], searches: [] };
window.qaBase = { role: '主操', bankTotal: 60, roleCounts: { 主操: 20, 班长: 20, 副操: 20 }, courses: ['安全课程'], canAnswer: true, canExport: true, pageMode: 'exercise', pageStatus: '已识别练习,可以选择答案。', storageMode: 'userscript' };
window.qaMakePanel = partial => {
window.qaPanel?.destroy();
window.qaPanel = YianAssistantPanel.create({
onPositionChange: value => window.qaCalls.positions.push({ left: value.left, top: value.top }),
onAutoChange: value => window.qaCalls.auto.push(value),
onCopyChange: value => window.qaCalls.copy.push(value),
onSearch: (query, settings) => window.qaCalls.searches.push({ query, settings })
});
window.qaPanel.setState(Object.assign({}, window.qaBase, partial || {}));
};
window.qaMakePanel({ expanded: false });
});
const host = page.locator('#yian-question-assistant');
const brand = host.locator('.brand');
const toggle = host.locator('.toggle');
async function rect() {
return page.evaluate(() => {
const box = window.qaPanel.host.getBoundingClientRect();
return { left: box.left, top: box.top, width: box.width, height: box.height, right: box.right, bottom: box.bottom, viewportWidth: innerWidth, viewportHeight: innerHeight };
});
}
async function visible() {
await page.waitForFunction(() => {
const panel = window.qaPanel.host;
const box = panel.getBoundingClientRect();
const header = window.qaPanel.root.querySelector('.header').getBoundingClientRect();
return box.width > 0 && box.height > 0 && box.left >= -1 && box.top >= -1 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1 && header.height > 0;
});
return rect();
}
async function positionSaves() { return page.evaluate(() => window.qaCalls.positions.length); }
async function isExpanded() { return toggle.getAttribute('aria-expanded'); }
async function startMouse(locator) {
const box = await locator.boundingBox();
assert(box, '拖动目标必须可见');
const point = { x: box.x + Math.min(box.width / 2, 50), y: box.y + box.height / 2 };
await page.mouse.move(point.x, point.y);
await page.mouse.down();
return point;
}
async function dragTo(left, top) {
const before = await rect();
const point = await startMouse(brand);
await page.mouse.move(point.x + left - before.left, point.y + top - before.top, { steps: 8 });
await page.mouse.up();
return visible();
}
async function screenshot(filename) { await page.screenshot({ path: path.join(qaDir, filename), fullPage: false }); }
const initial = await visible();
near(initial.viewportWidth - initial.right, 16, '默认右侧间距');
near(initial.viewportHeight - initial.bottom, 16, '默认底部间距');
assert.equal(await isExpanded(), 'false');
assert((await host.textContent()).trim().length > 0 || await brand.textContent(), '面板必须呈现内容');
await screenshot('01-desktop-default.png');
console.log('PASS independent module loads collapsed at bottom right');
const oldPosition = { left: Math.round(initial.left), top: Math.round(initial.top) };
const beforeDragSaves = await positionSaves();
const point = await startMouse(brand);
await page.mouse.move(point.x + 220 - initial.left, point.y + 60 - initial.top, { steps: 10 });
const duringDrag = await rect();
near(duringDrag.left, 220, '折叠面板水平拖动');
near(duringDrag.top, 60, '折叠面板垂直拖动');
assert.equal(await positionSaves(), beforeDragSaves, 'pointermove 不应持续保存位置');
await page.evaluate(position => window.qaPanel.setState({ position, bankTotal: 61 }), oldPosition);
const afterRefresh = await rect();
near(afterRefresh.left, duringDrag.left, '拖中旧 state 不回跳');
near(afterRefresh.top, duringDrag.top, '拖中旧 state 不回跳');
assert.equal(await positionSaves(), beforeDragSaves);
await page.mouse.up();
assert.equal(await positionSaves(), beforeDragSaves + 1, 'mouseup 应保存一次');
const dragged = await visible();
const saved = await page.evaluate(() => window.qaCalls.positions.at(-1));
near(saved.left, dragged.left, '保存的 left');
near(saved.top, dragged.top, '保存的 top');
assert.equal(await isExpanded(), 'false', '真实拖动产生的 click 不能展开面板');
await page.evaluate(position => window.qaPanel.setState({ position }), oldPosition);
near((await rect()).left, dragged.left, '拖后相同旧 state 不回跳');
assert.deepEqual(await page.evaluate(() => [qaCalls.auto, qaCalls.copy]), [[], []]);
await screenshot('02-desktop-collapsed-dragged.png');
console.log('PASS real collapsed drag saves only on end and ignores stale position refresh');
await brand.click();
assert.equal(await isExpanded(), 'true', '普通品牌点击应展开');
await visible();
await toggle.click();
assert.equal(await isExpanded(), 'false', '普通 toggle 点击应折叠');
const tinySaves = await positionSaves();
const tinyPoint = await startMouse(brand);
await page.mouse.move(tinyPoint.x + 2, tinyPoint.y + 1);
await page.mouse.up();
assert.equal(await isExpanded(), 'true', '小于 4px 移动仍是普通点击');
assert.equal(await positionSaves(), tinySaves, '微小移动不能被保存为拖动');
await visible();
console.log('PASS brand/toggle clicks work and movement below threshold remains a click');
const beforeExpandedDrag = await positionSaves();
const expanded = await dragTo(80, 20);
near(expanded.left, 80, '展开面板 left');
near(expanded.top, 20, '展开面板 top');
assert.equal(await positionSaves(), beforeExpandedDrag + 1);
assert.equal(await isExpanded(), 'true', '展开面板拖动不能误折叠');
const beforeToggleDrag = await rect();
const toggleSaves = await positionSaves();
const togglePoint = await startMouse(toggle);
await page.mouse.move(togglePoint.x + 120, togglePoint.y + 30, { steps: 5 });
await page.mouse.up();
near((await rect()).left, beforeToggleDrag.left, 'toggle 控件不能拖动面板');
near((await rect()).top, beforeToggleDrag.top, 'toggle 控件不能拖动面板');
assert.equal(await positionSaves(), toggleSaves);
assert.equal(await isExpanded(), 'true');
assert.deepEqual(await page.evaluate(() => [qaCalls.auto, qaCalls.copy]), [[], []]);
await screenshot('03-desktop-expanded-dragged.png');
console.log('PASS real expanded drag suppresses brand click; header controls do not drag');
const beforeControls = await rect();
const controlsSaves = await positionSaves();
await host.getByRole('switch', { name: '自动选择答案', exact: true }).click();
await host.getByRole('switch', { name: '允许复制文字', exact: true }).click();
await host.getByRole('textbox', { name: '搜索题目' }).fill('安全控制');
await host.getByRole('textbox', { name: '搜索题目' }).press('Enter');
assert.deepEqual(await page.evaluate(() => [qaCalls.auto, qaCalls.copy]), [[true], [false]]);
assert.equal(await page.evaluate(() => qaCalls.searches.at(-1).query), '安全控制');
near((await rect()).left, beforeControls.left, '开关、搜索不移动面板');
near((await rect()).top, beforeControls.top, '开关、搜索不移动面板');
assert.equal(await positionSaves(), controlsSaves);
const restoredPosition = await page.evaluate(() => window.qaCalls.positions.at(-1));
const restoreSaves = await positionSaves();
await page.evaluate(position => window.qaMakePanel({ expanded: true, position }), restoredPosition);
const restored = await visible();
near(restored.left, restoredPosition.left, '恢复 left');
near(restored.top, restoredPosition.top, '恢复 top');
assert.equal(await positionSaves(), restoreSaves, '合法位置恢复不应额外保存');
console.log('PASS controls/search remain usable and saved position restores in a new panel');
await page.evaluate(() => window.qaPanel.setState({ position: { left: 10000, top: -10000 } }));
const clamped = await visible();
assert(clamped.top >= 0 && clamped.right <= clamped.viewportWidth + 1, '超出视口的恢复位置应钳制');
await page.setViewportSize({ width: 390, height: 844 });
await visible();
await page.evaluate(() => window.qaPanel.renderSearch({ total: 20, items: Array.from({ length: 20 }, (_, index) => ({ number: index + 1, type: '单选', question: '检索结果内容变高后仍应在视口内。'.repeat(4), options: [{ label: 'A', text: '安全控制' }, { label: 'B', text: '其他选项' }], answer: 'A', role: '主操', course: '安全课程' })) }));
const grown = await visible();
assert(grown.height <= grown.viewportHeight + 1);
await screenshot('04-mobile-expanded-results.png');
await toggle.click();
assert.equal(await isExpanded(), 'false');
await dragTo(280, 800);
await toggle.click();
assert.equal(await isExpanded(), 'true');
await visible();
await page.setViewportSize({ width: 320, height: 480 });
const small = await visible();
assert(small.width <= 320 && small.height <= 480);
await screenshot('05-small-viewport.png');
console.log('PASS offscreen restore, content growth, fold/unfold and viewport resize remain visible');
await page.setViewportSize({ width: 1280, height: 900 });
await page.evaluate(() => window.qaPanel.setState({ expanded: true, position: { left: 120, top: 15 } }));
await visible();
const cancelPoint = await startMouse(brand);
await page.mouse.move(cancelPoint.x + 35, cancelPoint.y + 25, { steps: 4 });
const canceledAt = await rect();
await page.evaluate(() => window.qaPanel.root.querySelector('.header').dispatchEvent(new PointerEvent('pointercancel', { pointerId: 1, pointerType: 'mouse', button: 0, bubbles: true, composed: true, cancelable: true })));
const cancelSaves = await positionSaves();
await page.mouse.move(cancelPoint.x + 160, cancelPoint.y + 100, { steps: 4 });
await page.mouse.up();
near((await rect()).left, canceledAt.left, 'pointercancel 后不能继续拖');
near((await rect()).top, canceledAt.top, 'pointercancel 后不能继续拖');
assert.equal(await positionSaves(), cancelSaves, '取消后旧 pointerup 不能再次保存');
assert.equal(await page.evaluate(() => qaPanel.root.querySelector('.panel').classList.contains('dragging')), false);
assert.equal(await page.evaluate(() => { const event = new Event('selectstart', { bubbles: true, cancelable: true }); document.body.dispatchEvent(event); return event.defaultPrevented; }), false);
console.log('PASS pointercancel removes capture/global drag state and selection blocking');
assert.equal(await page.evaluate(() => getComputedStyle(qaPanel.root.querySelector('.header')).touchAction), 'none');
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
const touchBefore = await rect();
const touchBox = await brand.boundingBox();
const touchPoint = { x: Math.round(touchBox.x + 35), y: Math.round(touchBox.y + touchBox.height / 2) };
const touchSaves = await positionSaves();
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [touchPoint] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: touchPoint.x + 45, y: touchPoint.y + 35 }] });
assert.equal(await positionSaves(), touchSaves, '触屏移动不能持续保存');
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const touchAfter = await visible();
near(touchAfter.left, Math.max(0, Math.min(touchBefore.left + 45, touchBefore.viewportWidth - touchBefore.width)), 'CDP 真实触屏水平拖动');
near(touchAfter.top, Math.max(0, Math.min(touchBefore.top + 35, touchBefore.viewportHeight - touchBefore.height)), 'CDP 真实触屏垂直拖动及边界钳制');
assert.equal(await positionSaves(), touchSaves + 1);
assert.equal(await isExpanded(), 'true');
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: false });
await cdp.detach();
console.log('PASS touch-action is none and CDP touch drag saves once without folding');
const destroyPoint = await startMouse(brand);
await page.mouse.move(destroyPoint.x + 20, destroyPoint.y + 15);
assert.equal(await page.evaluate(() => { const event = new Event('selectstart', { bubbles: true, cancelable: true }); document.body.dispatchEvent(event); return event.defaultPrevented; }), true);
const destroySaves = await positionSaves();
await page.evaluate(() => window.qaPanel.destroy());
await page.mouse.move(destroyPoint.x + 80, destroyPoint.y + 60);
await page.mouse.up();
await page.setViewportSize({ width: 900, height: 650 });
assert.equal(await page.locator('#yian-question-assistant').count(), 0);
assert.equal(await positionSaves(), destroySaves, 'destroy 后不能保留回调或 resize 保存');
assert.equal(await page.evaluate(() => { const event = new Event('selectstart', { bubbles: true, cancelable: true }); document.body.dispatchEvent(event); return event.defaultPrevented; }), false);
assert.deepEqual(errors, []);
console.log('PASS destroy during dragging cleans listeners and observer; console remains clean');
console.log('Screenshots: ' + qaDir);
} finally {
if (browser) await browser.close();
await new Promise(resolve => server.close(resolve));
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+1 -1
View File
@@ -1,6 +1,6 @@
const { spawnSync } = require('node:child_process');
const path = require('node:path');
for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs', 'exam-assistant.cjs']) {
for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs']) {
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
if (result.status !== 0) process.exit(result.status || 1);
}
+31 -11
View File
@@ -1,14 +1,16 @@
/* 真实浏览器验证:只启动本机测试服务,不请求原网站。 */
/* 真实浏览器验证:主站 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');
@@ -56,11 +58,24 @@ function parseCSV(text) {
}
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
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()); });
@@ -176,7 +191,6 @@ function parseCSV(text) {
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(), '离线导出题库');
@@ -210,18 +224,24 @@ function parseCSV(text) {
await tool.screenshot({ path: path.join(qaDir, 'offline-mobile.png'), fullPage: true });
console.log('PASS 离线工具文件导入、三格式下载、无联网、桌面与手机布局');
// 验证原网页的文档级保存注释可以激活 file: 模式。
// 文档级保存注释不能绕过 file 范围限制,独立离线工具仍可用。
const local = await context.newPage();
const localHTML = '<!doctype html><!-- saved from url=(0100)http://10.119.67.7/clientcn/exam/local/questions?flag=exercise -->' + fixture('本地练习');
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(require('node:url').pathToFileURL(localPath).href);
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.evaluate(() => YianAssistant.ready);
const localDownload = local.waitForEvent('download');
await local.getByRole('switch', { name: '导出题库', exact: true }).check();
assert.equal((await localDownload).suggestedFilename(), '本地练习_题库_3题.csv');
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 本地保存网页自动识别与浏览器错误检查');
console.log('PASS file 保存网页不注入/下载或改变复制限制;独立离线导出工具保持可用,无外部请求与浏览器错误');
console.log('验证截图和下载文件:' + qaDir);
} finally {
if (browser) await browser.close();
File diff suppressed because one or more lines are too long