feat: add local question bank reset with stale-tab isolation
This commit is contained in:
@@ -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(); }
|
||||
});
|
||||
Reference in New Issue
Block a user