'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: '
本地题库重置验收
' })); 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(); } });