feat: add draggable panel and restrict assistant to site directory
This commit is contained in:
+180
-13
@@ -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
@@ -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, [], '考试不得自动或手动发起题库下载');
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user