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
+90 -2
View File
@@ -275,10 +275,14 @@ function bankPage(fetchImpl) {
const player = { get textContent() { return state.player; } };
const exam = { get textContent() { return state.exam; } };
const container = { getAttribute: key => key === 'task-id' ? state.containerTask : null };
const main = { querySelectorAll: () => [] };
const main = { querySelectorAll: selector => selector === '.yxtulcdsdk-examing__result-line' ? (state.feedback || []).map(value => ({
getAttribute: name => name === 'data-question-id' ? value.questionId || null : null,
querySelector: selector => selector === '.yxtulcdsdk-examing__result-line-left' ? { textContent: value.heading } : { textContent: value.answer },
closest: () => null
})) : [] };
const stem = { get textContent() { return state.stem; }, get innerHTML() { return state.stemHTML; }, matches: selector => selector === '[data-rich-text]' };
const options = ['降低温度', '提高温度'].map((value, index) => ({
querySelector: selector => selector === 'input' ? { value: `a${index + 1}` } : selector === '.yxtf-radio__label, .yxtf-checkbox__label' ? { textContent: `${String.fromCharCode(65 + index)}. ${value}`, querySelector: () => state.optionHTML ? { innerHTML: state.optionHTML[index], textContent: '' } : null } : null,
querySelector: selector => selector === 'input' ? { value: state.optionIds?.[index] || `a${index + 1}` } : selector === '.yxtf-radio__label, .yxtf-checkbox__label' ? { textContent: `${String.fromCharCode(65 + index)}. ${value}`, querySelector: () => state.optionHTML ? { innerHTML: state.optionHTML[index], textContent: '' } : null } : null,
textContent: value
}));
const form = {
@@ -650,3 +654,87 @@ test('DOM question number distinguishes legitimate identical items in the latest
assert.equal(page.adapter.getCurrentQuestion(), null, '题号与已加载完整列表不一致时应等待页面同步');
page.adapter.stop();
});
test('hidden parent answer permission cannot be bypassed by nested resultInfo', () => {
const raw = single();
raw.allowViewSingleQuesAnswer = false;
raw.resultInfo = { answers: ['a2'], judgeAnswer: 1 };
assert.equal(extractQuestions(raw)[0].answerStatus, 'missing');
});
test('old unbound feedback letters cannot become the next question standard answer', async () => {
const next = { ...single(), id: 'q2', content: '另一道题目?' };
next.choiceItems.forEach(option => delete option.answer);
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [single(), next] }), { headers: { 'content-type': 'application/json' } }));
page.adapter.start();
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
page.state.stem = '另一道题目?'; page.state.questionNumber = 2;
page.state.feedback = [{ heading: '正确答案', answer: 'A' }, { heading: '解析', answer: '上一题遗留解析' }];
const unbound = page.adapter.getCurrentQuestion();
assert.equal(unbound.questionId, 'q2');
assert.equal(unbound.answerStatus, 'missing');
assert.equal(unbound.explanation, '');
page.state.feedback = [{ heading: '正确答案', answer: 'A', questionId: 'q1' }];
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'missing', '明确属于上一题的反馈也不能套在新题');
page.state.feedback = [{ heading: '正确答案', answer: 'B', questionId: 'q2' }];
assert.deepEqual(page.adapter.getCurrentQuestion().answerTexts, ['提高温度']);
page.adapter.stop();
});
test('new stem with stale radio option ids waits until DOM values match its network identity', async () => {
const raw = { ...single(), id: 'q-new', choiceItems: [
{ id: 'new-a', content: '降低温度', answer: 0 }, { id: 'new-b', content: '提高温度', answer: 1 }
] };
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [raw] }), { headers: { 'content-type': 'application/json' } }));
page.adapter.start();
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
assert.equal(page.adapter.getCurrentQuestion(), null, '显示文字一致也不能给旧radio值绑定新题ID');
page.state.optionIds = ['new-a', 'new-b'];
const question = page.adapter.getCurrentQuestion();
assert.equal(question.questionId, 'q-new');
assert.deepEqual(question.options.map(option => option.id), ['new-a', 'new-b']);
page.adapter.stop();
});
test('observer ignores unrelated and selection mutations and batches relevant work before scanning', () => {
const page = bankPage();
let callback, reads = 0, sequence = 0;
const pending = new Map();
page.win.MutationObserver = class { constructor(value) { callback = value; } observe() {} };
page.win.setTimeout = fn => { const key = ++sequence; pending.set(key, fn); return key; };
page.win.clearTimeout = key => pending.delete(key);
const originalOne = page.doc.querySelector;
const originalAll = page.doc.querySelectorAll;
page.doc.querySelector = selector => { reads++; return originalOne(selector); };
page.doc.querySelectorAll = selector => { reads++; return originalAll(selector); };
function element(selectors, parentElement = null, id = '') {
const result = { nodeType: 1, id, parentElement,
matches: selector => selector.split(',').some(part => selectors.includes(part.trim()) || part.trim() === `#${id}`),
querySelector: () => null,
closest(selector) { return this.matches(selector) ? this : parentElement?.closest(selector) || null; }
};
return result;
}
page.adapter.start();
const baseline = reads;
const plain = element(['.unrelated-timer']);
const own = element([], null, 'huayi-qb-root');
const main = element([ROOT_SELECTOR_FOR_TEST]);
const radio = element(['.yxtulcdsdk-ques-radio'], main);
const card = element(['.yxtulcdsdk-answer-card__item']);
for (let i = 0; i < 1000; i++) callback([
{ type: 'characterData', target: { nodeType: 3, parentElement: plain } },
{ type: 'childList', target: own, addedNodes: [], removedNodes: [] },
{ type: 'attributes', attributeName: 'class', target: radio },
{ type: 'attributes', attributeName: 'class', target: card }
]);
assert.equal(reads, baseline, '无关页面变化不进行任何全页查询');
assert.equal(pending.size, 0, '选中/答题卡/助手自己的变化不调度扫描');
const stem = element(['.yxtulcdsdk-form__content'], main);
for (let i = 0; i < 100; i++) callback([{ type: 'characterData', target: { nodeType: 3, parentElement: stem } }]);
assert.equal(reads, baseline, '相关变化也不在observer回调里同步重扫');
assert.equal(pending.size, 1, '连续100次题目变化只调度一次检查');
const task = [...pending.values()][0]; pending.clear(); task();
assert.ok(reads > baseline, '合批后仍检查真实题目更新');
page.adapter.stop();
});
+49
View File
@@ -256,3 +256,52 @@ test('a question changing during the first click stops the rest of a multiple se
assert.equal(page.inputs[1].checked, false);
engine.destroy();
});
test('reusing the same input nodes with different option values stops the remaining clicks', async () => {
let page;
page = checkboxPage({ duringClick: index => {
if (index === 0) [page.inputs[1].value, page.inputs[2].value] = [page.inputs[2].value, page.inputs[1].value];
} });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
await engine.answerCurrent();
assert.deepEqual(page.clicks, [0], 'an old positional plan must not click a reused node now carrying another option ID');
engine.destroy();
});
test('a nested framework microtask that changes the question is observed before another click', async () => {
let page;
page = checkboxPage({ duringClick: index => {
if (index === 0) queueMicrotask(() => queueMicrotask(() => { page.title.textContent = '框架异步渲染的新题目。'; }));
} });
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
await engine.answerCurrent();
assert.deepEqual(page.clicks, [0], 'a single awaited Promise is too early to safely click the next option');
engine.destroy();
});
test('an 1884-record bank only normalizes exact-stem candidates, not every unrelated question per poll', () => {
const records = Array.from({ length: 1884 }, (_, index) => single({ questionId: `question-${index}`, stem: `无关的题库练习 ${index}。` }));
records[1000] = single();
let normalizedCount = 0;
const observed = { ...core, normalizeRecord(value) { normalizedCount++; return core.normalizeRecord(value); } };
assert.equal(planAnswer(records, current(), observed).status, 'ready');
const initial = normalizedCount;
for (let attempt = 0; attempt < 20; attempt++) assert.equal(planAnswer(records, current(), observed).status, 'ready');
assert.ok(initial <= 3, `first lookup should not normalize all 1884 records (observed ${initial})`);
assert.ok(normalizedCount - initial <= 20, 'repeat lookups only need to validate the current question again');
});
test('the answer index detects in-place option/answer mutations and changed raw records', () => {
const records = [single()];
assert.equal(planAnswer(records, current(), core).status, 'ready');
records[0].correctOptionIds = ['a']; records[0].answerTexts = ['降低温度'];
assert.deepEqual(planAnswer(records, current(), core).labels, ['B']);
records[0].answerStatus = 'conflict';
assert.equal(planAnswer(records, current(), core).status, 'ambiguous');
records[0] = single();
assert.deepEqual(planAnswer(records, current(), core).labels, ['C']);
records.push(single({ questionId: 'changed-stem', stem: '此前与目标无关。', correctOptionIds: ['a'], answerTexts: ['降低温度'] }));
assert.equal(planAnswer(records, current(), core).status, 'ready');
records[1].stem = single().stem;
assert.equal(planAnswer(records, current(), core).status, 'ambiguous', 'a record changing into an exact match must invalidate the question index');
});
+71
View File
@@ -709,3 +709,74 @@ test('metadata validation refuses corrupt occurrences and preserves explicit ali
assert.deepEqual(core.normalizeRecord(JSON.parse(JSON.stringify(record))), record);
assert.deepEqual(core.fromCSV(core.toCSV([record])), [record]);
});
test('indexed aliases retain traversal order when a later match reveals more aliases', () => {
const rows = [
fixture({ questionId: 'root', questionIdAliases: ['root', 'bridge'], explanation: '根记录' }),
fixture({ questionId: 'earlier', explanation: '较早记录' }),
fixture({ questionId: 'bridge', questionIdAliases: ['bridge', 'earlier', 'later', 'after'], explanation: '桥接记录' }),
fixture({ questionId: 'later', explanation: '最后记录' }),
fixture({ questionId: 'bridge', bankId: 'other-bank', explanation: '另一题库' }),
fixture({ questionId: 'later', stem: '同ID别名但内容不同' }),
fixture({ questionId: 'after', explanation: '后续新增别名记录' }),
];
const result = core.upsert([], rows);
assert.deepEqual(result.map(record => [record.bankId, record.questionId]), [
['bank-1', 'root'], ['bank-1', 'earlier'], ['bank-1', 'later'], ['other-bank', 'bridge']
]);
// The late alias was not known while visiting the earlier position. Preserve
// that existing one-pass behavior rather than changing the answer's history.
assert.deepEqual(result[0].questionIdAliases, ['root', 'bridge', 'earlier', 'later', 'after']);
assert.equal(result[0].explanation, '后续新增别名记录');
assert.equal(result[2].answerStatus, 'conflict');
assert.deepEqual(core.fromCSV(core.toCSV(result)), result);
});
test('indexed anonymous promotion remains unique in both directions and preserves record order', () => {
const rows = [
fixture({ questionId: '', contentOccurrence: 0 }),
fixture({ questionId: 'unrelated', stem: '另一个题干' }),
fixture({ questionId: '', contentOccurrence: 1 }),
fixture({ questionId: 'ambiguous' }),
fixture({ questionId: 'unique', stem: '唯一可升级题干' }),
fixture({ questionId: '', stem: '唯一可升级题干', explanation: '后出现的说明' }),
];
const result = core.upsert([], rows);
assert.deepEqual(result.map(record => record.questionId), ['', 'unrelated', '', 'ambiguous', 'unique']);
assert.deepEqual(result.filter(record => !record.questionId).map(record => record.contentOccurrence), [0, 1]);
assert.equal(result.at(-1).explanation, '后出现的说明');
});
test('fast internal merges preserve fresh DOM metadata and revalidate externally mutated inputs', () => {
const original = core.normalizeRecord(fixture({ questionIndex: 3, contentOccurrence: 0, questionIdAliases: ['old'] }));
const dom = { ...original, answerStatus: 'missing', correctOptionIds: [], answerTexts: [], explanation: '新增解析',
questionIdAliases: ['another-id'], updatedAt: '2026-10-30T01:00:00Z',
source: { kind: 'dom', url: 'https://practice.example.test/new', observedAt: '2026-10-30T01:00:00Z' } };
const merged = core.upsert([original], [dom])[0];
assert.deepEqual(merged.answerTexts, original.answerTexts);
assert.deepEqual(merged.correctOptionIds, original.correctOptionIds);
assert.deepEqual(merged.answerSource, original.answerSource);
assert.deepEqual(merged.source, dom.source);
assert.equal(merged.updatedAt, dom.updatedAt);
assert.equal(merged.explanation, dom.explanation);
assert.deepEqual(merged.questionIdAliases, ['old', original.questionId, 'another-id']);
assert.equal(merged.questionIndex, 3);
assert.equal(merged.contentOccurrence, 0);
dom.options = dom.options.map(option => option.id === 'y' ? { ...option, text: '改变选项内容' } : option);
assert.equal(core.upsert([original], [dom])[0].answerStatus, 'conflict');
assert.equal(original.options.find(option => option.id === 'y').text, '提高温度');
});
test('large repeated alias banks preserve every canonical record and complete CSV content', () => {
const records = Array.from({ length: 600 }, (_, index) => core.normalizeRecord(fixture({
questionId: `canonical-${index}`, questionIdAliases: [`canonical-${index}`, `old-${index}`],
stem: `设备检查${index}:P水 > P气,面积2m²`, questionIndex: index, contentOccurrence: 0,
source: { kind: 'network', url: 'https://example.test/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'complete-list' }
})));
const result = core.upsert(records, records);
assert.equal(JSON.stringify(result), JSON.stringify(records));
assert.equal(core.toCSV(result), core.toCSV(records));
const compacted = core.compactRecords(result);
assert.equal(compacted.removed, 0);
assert.equal(JSON.stringify(compacted.records), JSON.stringify(records));
});
+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));
});