fix: reduce userscript page blocking and stale option clicks

This commit is contained in:
suanyuit-cyber
2026-10-30 01:47:03 +08:00
parent 26e0a920b6
commit 4bf2de1bad
12 changed files with 1128 additions and 170 deletions
+187
View File
@@ -0,0 +1,187 @@
'use strict';
// Local-only performance regression probe. It compares the committed 1.2.4
// sources with the working tree without rewriting dist or visiting an account.
const test = require('node:test');
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 { execFileSync } = require('node:child_process');
const ROOT = path.resolve(__dirname, '..');
const DATASET = path.join(ROOT, '题库/华谊集团化工总控工(高级)-合并题库.json');
const OUTPUT = path.join(os.tmpdir(), 'huayi-qb-performance');
const RECORDS_KEY = 'huayi-qb.records.v1';
const SOURCES = ['src/core.js', 'src/adapter.js', 'src/autofill.js', 'src/app.js'];
// This was HEAD/package version 1.2.4 when the reported regression was captured.
// Pin it so committing the fix does not silently turn the baseline into itself.
const BASELINE_REF = process.env.QB_PERF_BASE_REF || '26e0a920b6d56603bfee1061014c5052a7366ec2';
function loadPlaywright() {
if (process.env.QB_PLAYWRIGHT_MODULE) return require(process.env.QB_PLAYWRIGHT_MODULE);
try { return require('playwright'); }
catch (_) { return require(path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')); }
}
function browserOptions() {
const requested = process.env.QB_CHROMIUM_EXECUTABLE || process.env.QB_CHROMIUM_PATH;
const cached = path.join(os.homedir(), 'AppData/Local/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-win64/chrome-headless-shell.exe');
return { headless: true, ...(requested || fs.existsSync(cached) ? { executablePath: requested || cached } : {}) };
}
function html(value) { return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function fixture(record) {
const title = html(record.bankName);
const inputs = record.options.map(option => `<label class="yxtulcdsdk-ques-radio"><span class="yxtf-radio__input"><input type="radio" name="probe-answer" value="${html(option.id)}"></span><span class="yxtf-radio__label"><span data-rich-text="1">${html(option.text)}</span></span></label>`).join('');
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${title}</title><style>
body{font:16px/1.7 system-ui;margin:28px;background:#f2f5f8;color:#304455}.task-content-active{padding:10px;border:1px solid #cbd9e2;background:white}.yxtulcdsdk-exam-container{padding:24px;background:white;margin-top:20px;max-width:800px}.yxtulcdsdk-ques-radio{display:block;padding:10px;border:1px solid #dce4ec;margin:12px 0}#probe-clock{font-variant-numeric:tabular-nums}#probe-cards{display:flex;gap:5px}#probe-cards i{font-style:normal;padding:3px 8px;background:#edf1f5}.current{color:#008d78}
</style></head><body><div class="task-content-active" data-task-id="perf-task"><span class="group-title">${title}</span></div><header class="o2oPlayFrame-right"><div class="title"><span class="yxtf-tooltip">${title}</span></div></header>
<p>本地性能回归:1884 道题库记录,面板保持隐藏。</p><p>页面计时器:<span id="probe-clock">0</span></p><div id="probe-cards">${Array.from({ length: 20 }, (_, i) => `<i>${i + 1}</i>`).join('')}</div>
<div class="yxtulcdsdk-exam-container" task-id="perf-task"><div class="yxtulcdsdk-exam-header"><span class="yxtf-tooltip">${title}</span></div><div class="yxtulcdsdk-pc-marking-main"><div class="standard-size-14"><span>判断题</span></div><div class="yxtulcdsdk-form"><div class="yxtulcdsdk-form__sub">${(record.questionIndex || 0) + 1}.</div><div class="yxtulcdsdk-form__content"><div data-rich-text="1">${html(record.stem)}</div><div class="yxtulcdsdk-ques-radio-group">${inputs}</div></div></div></div></div></body></html>`;
}
function installProbe(seed) {
const metrics = { calls: {}, times: {}, recordsWrites: 0, storageWrites: 0, storageReads: 0, questionScans: 0, hiddenResultRebuilds: 0, hiddenBankRebuilds: 0, hiddenTextWrites: 0 };
const values = new Map([['huayi-qb.records.v1', seed], ['huayi-qb.config.v1', { mode: 'hidden', keepLauncher: false, collecting: true, copyEnabled: true, tab: 'search' }]]);
window.__perf = { metrics, started: performance.now(), rootReadyAt: null, errors: [] };
window.unsafeWindow = window;
window.GM_getValue = (key, fallback) => { metrics.storageReads++; return structuredClone(values.has(key) ? values.get(key) : fallback); };
window.GM_setValue = (key, value) => { metrics.storageWrites++; if (key === 'huayi-qb.records.v1') metrics.recordsWrites++; values.set(key, structuredClone(value)); };
window.GM_registerMenuCommand = () => 1;
window.GM_addValueChangeListener = () => 1;
window.GM_removeValueChangeListener = () => {};
const questionQuery = Document.prototype.querySelectorAll;
Document.prototype.querySelectorAll = function (selector) {
if (String(selector).includes('.yxtulcdsdk-pc-marking-main') && String(selector).includes('.yxtulcdsdk-form')) metrics.questionScans++;
return questionQuery.call(this, selector);
};
const replace = Element.prototype.replaceChildren;
Element.prototype.replaceChildren = function (...items) {
const root = this.getRootNode();
if (root.host?.id === 'huayi-qb-root' && root.querySelector('#qb-panel')?.hidden) {
if (this.id === 'qb-results') metrics.hiddenResultRebuilds++;
if (this.id === 'qb-bank-list') metrics.hiddenBankRebuilds++;
}
return replace.apply(this, items);
};
const content = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');
Object.defineProperty(Node.prototype, 'textContent', { ...content, set(value) {
const root = this.getRootNode();
if (root.host?.id === 'huayi-qb-root' && root.querySelector('#qb-panel')?.hidden) metrics.hiddenTextWrites++;
content.set.call(this, value);
} });
const appearance = new MutationObserver(() => {
if (window.__perf.rootReadyAt == null && document.getElementById('huayi-qb-root')) window.__perf.rootReadyAt = performance.now();
});
appearance.observe(document, { childList: true, subtree: true });
window.__perf.cloneMetrics = () => JSON.parse(JSON.stringify(metrics));
window.__perf.recordCount = () => values.get('huayi-qb.records.v1').length;
}
const CORE_PROBE = `
for (const name of Object.keys(QBCore)) {
if (typeof QBCore[name] !== 'function') continue;
const original = QBCore[name];
QBCore[name] = function (...args) {
const start = performance.now(), m = window.__perf.metrics;
m.calls[name] = (m.calls[name] || 0) + 1;
try { return original.apply(this, args); }
finally { m.times[name] = (m.times[name] || 0) + performance.now() - start; }
};
}
`;
function sourceFor(committed) {
return `(function(){'use strict';\n${SOURCES.map((file, index) => {
const code = committed ? execFileSync('git', ['show', `${BASELINE_REF}:${file}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }) : fs.readFileSync(path.join(ROOT, file), 'utf8');
return code + (index === 0 ? '\n' + CORE_PROBE : '');
}).join('\n')}\n})();`;
}
function delta(end, start) {
const result = {};
for (const key of Object.keys(end)) {
if (end[key] && typeof end[key] === 'object') {
result[key] = {};
for (const name of new Set([...Object.keys(end[key]), ...Object.keys(start[key] || {})])) {
const value = (end[key][name] || 0) - (start[key]?.[name] || 0);
if (value) result[key][name] = key === 'times' ? +value.toFixed(2) : value;
}
} else result[key] = end[key] - (start[key] || 0);
}
return result;
}
test('1884题真实浏览器性能:启动、无关DOM变化和一次语义换题', { timeout: 180000, skip: !fs.existsSync(DATASET) }, async t => {
const payload = JSON.parse(fs.readFileSync(DATASET, 'utf8').replace(/^\uFEFF/, ''));
const records = Array.isArray(payload) ? payload : payload.records;
assert.equal(records.length, 1884, '性能样本应为用户当前合并的1884条题库');
const initial = records.find(record => record.type === 'judge' && !record.stem.includes('[图片:'));
assert.ok(initial);
fs.mkdirSync(OUTPUT, { recursive: true });
const server = http.createServer((request, response) => {
if (request.url === '/favicon.ico') { response.writeHead(204); response.end(); return; }
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); response.end(fixture(initial));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const { chromium } = loadPlaywright();
const browser = await chromium.launch(browserOptions());
const mode = process.env.QB_PERF_MODE || 'compare';
const results = {};
try {
for (const [name, committed] of [['before', true], ['after', false]]) {
if ((mode === 'baseline' && !committed) || (mode === 'after' && committed)) continue;
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' }));
await context.addInitScript({ content: `(${installProbe.toString()})(${JSON.stringify(records)});\n${sourceFor(committed)}` });
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
try {
await page.goto(`${origin}/o2o/#/playinfo?taskId=perf-task`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await page.waitForTimeout(1000);
const startup = await page.evaluate(() => ({ ...window.__perf.cloneMetrics(), readyMs: +(window.__perf.rootReadyAt - window.__perf.started).toFixed(2), storedCount: window.__perf.recordCount() }));
assert.equal(await page.locator('#qb-panel').isVisible(), false);
const beforeNoise = await page.evaluate(() => window.__perf.cloneMetrics());
await page.evaluate(() => {
let tick = 0;
window.__noiseTimer = setInterval(() => {
document.getElementById('probe-clock').textContent = String(++tick);
document.querySelectorAll('#probe-cards i').forEach((card, index) => card.classList.toggle('current', index === tick % 20));
}, 50);
});
await page.waitForTimeout(1800);
await page.evaluate(() => clearInterval(window.__noiseTimer));
await page.waitForTimeout(500);
const afterNoise = await page.evaluate(() => window.__perf.cloneMetrics());
const noise = delta(afterNoise, beforeNoise);
const beforeChange = await page.evaluate(() => window.__perf.cloneMetrics());
await page.evaluate(() => {
document.querySelector('.yxtulcdsdk-form__sub').textContent = '2.';
document.querySelector('.yxtulcdsdk-form__content > [data-rich-text]').textContent = '本地性能探针:新增一题用于检测合批持久化。';
});
await page.waitForTimeout(1700);
const afterChange = await page.evaluate(() => window.__perf.cloneMetrics());
const change = delta(afterChange, beforeChange);
results[name] = { startup, unrelatedChanges: noise, semanticChange: change, finalStoredCount: await page.evaluate(() => window.__perf.recordCount()), errors };
await page.screenshot({ path: path.join(OUTPUT, `${name}-hidden.png`) });
assert.deepEqual(errors, [], '本地性能夹具不能出现脚本异常');
if (!committed) {
assert.equal(noise.calls.upsert || 0, 0, '无关计时器/答题卡class变化不能合并全题库');
assert.equal(noise.calls.compactRecords || 0, 0, '无关变化不能重新整理全题库');
assert.equal(noise.recordsWrites, 0, '无关变化不能写回题库');
assert.equal(noise.hiddenResultRebuilds, 0, '隐藏时不能重建检索结果');
assert.equal(change.hiddenResultRebuilds, 0, '语义换题也不应重建隐藏检索结果');
assert.equal(change.recordsWrites, 1, '一次新题语义变化应合批为一次题库写入');
assert.ok((change.calls.upsert || 0) <= 1, '一次变化不得在保存时再次全量upsert');
}
} finally { await context.close(); }
}
} finally {
await browser.close();
await new Promise(resolve => server.close(resolve));
fs.writeFileSync(path.join(OUTPUT, `performance-${mode}.json`), JSON.stringify(results, null, 2));
}
t.diagnostic(`本地真实1884题;Browser plugin not available,使用已有Playwright/Chromium;结果 ${path.join(OUTPUT, `performance-${mode}.json`)}`);
t.diagnostic(JSON.stringify(results));
});