feat: add local question bank reset with stale-tab isolation

This commit is contained in:
suanyuit-cyber
2026-11-03 03:55:22 +08:00
parent 3df021e8d1
commit a7d2d30daa
8 changed files with 666 additions and 83 deletions
+227
View File
@@ -0,0 +1,227 @@
'use strict';
// Exercise the built userscript with real Chromium and normal DOM controls.
// All requests are fulfilled locally; no account or production API is used.
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 ROOT = path.resolve(__dirname, '..');
const RECORDS = 'huayi-qb.records.v1', CONFIG = 'huayi-qb.config.v1', RESET = 'huayi-qb.library-reset.v1';
const BACKUPS = ['before-dedup', 'before-duplicate-option-repair', 'before-image-upgrade', 'before-judge-evidence-repair']
.map(name => `huayi-qb.records.${name}.v1`);
const seed = [{ bankId: 'reset-bank', bankName: '旧导入题库', questionId: 'old-question', type: 'single', stem: '旧题库中的错误答案记录。',
options: [{ id: 'a', label: 'A', text: '旧选项一' }, { id: 'b', label: 'B', text: '旧选项二' }],
correctOptionIds: ['a'], answerTexts: ['旧选项一'], answerStatus: 'confirmed' }];
function installStorage(seed, fallback) {
window.unsafeWindow = window;
window.__menus = [];
window.GM_registerMenuCommand = (name, action) => window.__menus.push({ name, action });
if (!fallback) {
window.GM_getValue = (key, defaultValue) => {
const value = localStorage.getItem(key);
return value === null ? defaultValue : JSON.parse(value);
};
window.GM_setValue = (key, value) => {
const blocked = window.__blockWrite;
if (blocked && (blocked === key || (blocked === 'staging' && key.includes('.reset.')))) {
if (window.__silentFailure) return;
throw new Error('fixture storage denied');
}
localStorage.setItem(key, JSON.stringify(value));
};
window.GM_deleteValue = key => {
if (window.__blockCleanup) throw new Error('fixture cleanup denied');
localStorage.removeItem(key);
};
const listeners = new Map(); let nextId = 0;
window.GM_addValueChangeListener = (key, action) => { const id = ++nextId; listeners.set(id, { key, action }); return id; };
window.GM_removeValueChangeListener = id => listeners.delete(id);
window.addEventListener('storage', event => {
if (window.__muteNotifications) return;
for (const entry of [...listeners.values()]) if (entry.key === event.key) {
entry.action(event.key, event.oldValue === null ? null : JSON.parse(event.oldValue), event.newValue === null ? null : JSON.parse(event.newValue), true);
}
});
}
// Simulate a suspended tab that misses both GM and native storage events.
window.addEventListener('storage', event => { if (window.__muteNotifications) event.stopImmediatePropagation(); }, true);
if (!localStorage.getItem('__resetFixtureSeeded')) {
localStorage.setItem('__resetFixtureSeeded', '1');
localStorage.setItem('huayi-qb.records.v1', JSON.stringify(seed));
localStorage.setItem('huayi-qb.config.v1', JSON.stringify({ collecting: false, copyEnabled: false }));
for (const suffix of ['before-dedup', 'before-duplicate-option-repair', 'before-image-upgrade', 'before-judge-evidence-repair']) {
localStorage.setItem(`huayi-qb.records.${suffix}.v1`, JSON.stringify({ schemaVersion: 1, records: seed, migrations: [] }));
}
localStorage.setItem('unrelated-site-data', 'preserve me');
}
}
function activeRecords(page) {
return page.evaluate(({ RECORDS, RESET }) => {
const marker = JSON.parse(localStorage.getItem(RESET) || 'null');
return JSON.parse(localStorage.getItem(marker ? `${RECORDS}.reset.${marker.generation}` : RECORDS) || '[]');
}, { RECORDS, RESET });
}
async function show(page, tab = 'library') {
if (!await page.locator('#qb-panel').isVisible()) await page.keyboard.press('Alt+Shift+Q');
await page.locator(`[data-tab="${tab}"]`).click();
}
async function reset(page, accept = true, tab = 'library') {
await show(page, tab);
const dialog = page.waitForEvent('dialog');
const clicking = page.locator(`#qb-view-${tab} [data-action="reset-library"]`).click();
const prompt = await dialog;
assert.match(prompt.message(), /全部.*道题/);
await (accept ? prompt.accept() : prompt.dismiss());
await clicking;
}
async function importJSON(page, records, name = 'verified.json') {
await show(page);
await page.locator('#qb-import-file').setInputFiles({ name, mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ schemaVersion: 1, records })) });
await page.waitForFunction(() => document.querySelector('#huayi-qb-root').shadowRoot.querySelector('#qb-import-file').files.length === 0);
}
test('重置题库:清空、旧标签隔离、异步取消和写入失败保护', { timeout: 90000 }, async t => {
let playwright;
try { playwright = require(process.env.QB_PLAYWRIGHT_MODULE || 'playwright'); }
catch { playwright = require(path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')); }
const cached = path.join(os.homedir(), 'AppData/Local/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-win64/chrome-headless-shell.exe');
const requested = process.env.QB_CHROMIUM_EXECUTABLE || process.env.QB_CHROMIUM_PATH;
const browser = await playwright.chromium.launch({ headless: true, ...(requested || fs.existsSync(cached) ? { executablePath: requested || cached } : {}) });
const script = fs.readFileSync(path.join(ROOT, 'dist/huayi-question-bank.user.js'), 'utf8');
const errors = [];
async function fixture(fallback = false) {
const context = await browser.newContext();
await context.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><html><body><main>本地题库重置验收</main></body></html>' }));
await context.addInitScript({ content: `(${installStorage.toString()})(${JSON.stringify(seed)},${fallback});\n${script}` });
async function open() {
const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
await page.goto('https://reset.example.test/');
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
return page;
}
return { context, open, page: await open() };
}
try {
await t.test('取消保持原库;确认清空题目、冲突和四类备份,保留其他网站数据与复制设置', async () => {
const { context, page } = await fixture();
try {
const before = await activeRecords(page);
await reset(page, false);
assert.deepEqual(await activeRecords(page), before);
assert.equal(await page.evaluate(key => localStorage.getItem(key), RESET), null);
await show(page, 'auto');
await page.locator('#qb-auto-enabled').check();
await reset(page);
assert.deepEqual(await activeRecords(page), []);
assert.equal(await page.locator('#qb-stat-total').innerText(), '0');
for (const key of [RECORDS, ...BACKUPS]) assert.equal(await page.evaluate(key => localStorage.getItem(key), key), null);
assert.equal(await page.evaluate(() => localStorage.getItem('unrelated-site-data')), 'preserve me');
await show(page, 'auto');
assert.equal(await page.locator('#qb-auto-enabled').isChecked(), false);
await show(page, 'settings');
assert.equal(await page.locator('#qb-copy-enabled').isChecked(), false);
await page.reload(); await show(page);
assert.deepEqual(await activeRecords(page), []);
assert.equal(await page.locator('[data-action="toggle-capture"]').innerText(), '继续采集');
assert.ok(await page.evaluate(() => window.__menus.some(entry => entry.name === '重置全部本地题库')));
} finally { await context.close(); }
});
await t.test('跨标签同步;漏通知的旧页在保存前清空,旧版写回与刷新均不能复活数据', async () => {
const { context, page: a, open } = await fixture();
try {
const b = await open(), suspended = await open();
await show(b); await show(suspended);
await suspended.evaluate(() => { window.__muteNotifications = true; });
await reset(a);
await b.waitForFunction(() => document.querySelector('#huayi-qb-root').shadowRoot.querySelector('#qb-stat-total').textContent === '0');
assert.equal(await suspended.locator('#qb-stat-total').innerText(), '1');
await suspended.evaluate(() => window.dispatchEvent(new Event('pagehide')));
assert.equal(await suspended.locator('#qb-stat-total').innerText(), '0');
await a.evaluate(({ RECORDS, CONFIG, BACKUPS, seed }) => {
localStorage.setItem(RECORDS, JSON.stringify(seed));
localStorage.setItem(CONFIG, JSON.stringify({ collecting: true }));
for (const key of BACKUPS) localStorage.setItem(key, JSON.stringify({ schemaVersion: 1, records: seed, migrations: [] }));
}, { RECORDS, CONFIG, BACKUPS, seed });
await a.reload(); await show(a);
assert.deepEqual(await activeRecords(a), []);
assert.equal(await a.locator('#qb-stat-total').innerText(), '0');
assert.equal(await a.locator('[data-action="toggle-capture"]').innerText(), '继续采集');
await show(a, 'settings');
await a.locator('[data-action="export-judge-evidence-backup"]').click();
assert.match(await a.locator('#qb-status').innerText(), /没有判断题/);
} finally { await context.close(); }
});
await t.test('重置取消正在读取的旧文件,新导入不受旧异步完成影响;1884题重新导入导出一致', async () => {
const { context, page: a, open } = await fixture();
try {
const b = await open(); await show(b);
await b.evaluate(() => {
const original = File.prototype.text;
File.prototype.text = function () {
if (this.name !== 'delayed.json') return original.call(this);
return new Promise(resolve => { window.__releaseImport = async () => resolve(await original.call(this)); });
};
});
await b.locator('#qb-import-file').setInputFiles({ name: 'delayed.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ schemaVersion: 1, records: seed })) });
await b.waitForFunction(() => typeof window.__releaseImport === 'function');
await reset(a, true, 'settings');
await b.waitForFunction(() => !document.querySelector('#huayi-qb-root').shadowRoot.querySelector('[data-action="import"]').disabled);
const verified = JSON.parse(fs.readFileSync(path.join(ROOT, '题库/华谊集团化工总控工(高级)-合并题库.json'), 'utf8')).records;
await importJSON(b, verified);
await b.evaluate(() => window.__releaseImport());
assert.equal((await activeRecords(b)).length, 1884);
assert.equal((await activeRecords(b)).some(record => record.questionId === seed[0].questionId), false);
const waiting = b.waitForEvent('download');
await b.locator('[data-action="export-json"]').click();
const download = await waiting;
const exported = JSON.parse(fs.readFileSync(await download.path(), 'utf8'));
assert.equal(exported.records.length, 1884);
assert.ok(exported.records.every(record => record.answerStatus === 'confirmed'));
const oldMarker = await b.evaluate(key => JSON.parse(localStorage.getItem(key)), RESET);
await reset(b);
assert.equal(await b.evaluate(({ RECORDS, generation }) => localStorage.getItem(`${RECORDS}.reset.${generation}`), { RECORDS, generation: oldMarker.generation }), null);
assert.deepEqual(await activeRecords(b), []);
} finally { await context.close(); }
});
await t.test('空库写入失败、标记写入失败及静默失败都保留原题库和备份', async () => {
for (const failure of [{ key: 'staging', silent: false }, { key: RESET, silent: false }, { key: RESET, silent: true }]) {
const { context, page } = await fixture();
try {
const before = await activeRecords(page);
await page.evaluate(failure => { window.__blockWrite = failure.key; window.__silentFailure = failure.silent; }, failure);
await reset(page);
assert.match(await page.locator('#qb-status').innerText(), /重置未完成/);
assert.deepEqual(await activeRecords(page), before);
assert.equal(await page.evaluate(key => localStorage.getItem(key), RESET), null);
for (const key of BACKUPS) assert.ok(await page.evaluate(key => localStorage.getItem(key), key));
} finally { await context.close(); }
}
});
await t.test('原存储损坏仍可重置;清理失败明确提示并隔离,localStorage回退同样有效', async () => {
for (const fallback of [false, true]) {
const { context, page } = await fixture(fallback);
try {
await page.evaluate(({ RECORDS, BACKUPS }) => {
localStorage.setItem(RECORDS, '{broken');
localStorage.setItem(BACKUPS[2], '{broken');
}, { RECORDS, BACKUPS });
await page.reload(); await show(page);
if (!fallback) await page.evaluate(() => { window.__blockCleanup = true; });
await reset(page);
assert.deepEqual(await activeRecords(page), []);
if (!fallback) assert.match(await page.locator('#qb-status').innerText(), /部分旧存储清理失败.*隔离/);
await page.reload(); await show(page);
assert.equal(await page.locator('#qb-stat-total').innerText(), '0');
} finally { await context.close(); }
}
});
assert.deepEqual(errors, []);
} finally { await browser.close(); }
});