/* Complete userscript diagnostics workflow; every business request is intercepted. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..');
const bundle = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
const qaDir = path.join(os.tmpdir(), 'yianxue-diagnostics-qa');
const course = '诊断回归测试课程';
const targetURL = 'http://10.119.67.7/clientcn/course/diagnostics?token=SECRET';
const settingPrefix = 'yianxue.assistant.setting.v2.';
const learningPrefix = settingPrefix + 'learning:';
const bankKey = 'yianxue.assistant.bank.v3';
const html = '
诊断面板本地回归诊断面板本地模拟
' + course + '
诊断演示视频
视频
尚未完成
';
const sandbox = '(function(){const GM_getValue=(key,fallback)=>window.__diagnosticsRead(key,fallback);const GM_setValue=(key,value)=>window.__diagnosticsWrite(key,value);\n' + bundle + '\n})();';
(async () => {
fs.mkdirSync(qaDir, { recursive: true });
const memory = new Map();
const errors = [], navigationRequests = [], unexpectedRequests = [];
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
try {
const context = await browser.newContext({ viewport: { width: 1366, height: 900 }, acceptDownloads: true });
await context.exposeBinding('__diagnosticsRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__diagnosticsWrite', (_, key, value) => memory.set(key, structuredClone(value)));
await context.route('**/*', route => {
if (route.request().isNavigationRequest() && route.request().url() === targetURL) {
navigationRequests.push(route.request().url());
return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: html });
}
unexpectedRequests.push(route.request().url()); return route.abort();
});
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
async function install(offset = 0) {
await page.evaluate(offset => {
window.qaOffset = offset;
Date.now = () => Date.UTC(2026, 9, 18, 10) + qaOffset;
window.qaMedia = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1, plays: 0 };
const video = document.querySelector('video');
for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => qaMedia[key], set: value => { qaMedia[key] = value; } });
video.play = async () => { qaMedia.plays++; qaMedia.paused = false; };
video.pause = () => { qaMedia.paused = true; };
}, offset);
await page.addScriptTag({ content: sandbox });
await page.evaluate(() => YianAssistant.ready);
assert.equal(await page.evaluate(() => typeof YianAssistant.getDiagnosticState), 'function', '完整bundle必须提供真实诊断API');
}
async function consoleTab() {
const expand = page.getByRole('button', { name: '展开题库助手', exact: true });
if (await expand.isVisible()) await expand.click();
await page.getByTestId('tab-console').click();
await page.getByTestId('diagnostic-status').waitFor({ state: 'visible' });
}
async function download(format, invoke) {
const waiting = page.waitForEvent('download');
await invoke();
const result = await waiting;
assert.match(result.suggestedFilename(), new RegExp('\\.' + format + '$'));
assert.equal(await result.failure(), null);
const destination = path.join(qaDir, 'diagnostics-' + format + '.' + format);
await result.saveAs(destination);
const text = fs.readFileSync(destination, 'utf8');
for (const forbidden of ['SECRET', '不可写入诊断的题干', '不可写入诊断的正确答案']) assert(!text.includes(forbidden), forbidden + '不得出现在导出诊断中');
return text;
}
const protectedStorage = () => [...memory].filter(([key]) => key === bankKey || key.startsWith(learningPrefix)).sort(([a], [b]) => a.localeCompare(b));
await page.goto(targetURL); await install();
assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().enabled), true);
const bank = [{ course, type: '单选', question: '不可写入诊断的题干', options: [{ label: 'A', text: '不可写入诊断的正确答案' }, { label: 'B', text: '错误项' }], answer: 'A' }];
await page.evaluate(async bank => {
const bytes = new TextEncoder().encode(JSON.stringify(bank));
await YianAssistant.importFiles([{ name: '诊断回归题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
await YianAssistant.taskAction('start');
}, bank);
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused);
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), 1);
const control = memory.get(learningPrefix + 'control.v1');
const task = await page.evaluate(() => YianAssistant.getLearningState().tasks.find(task => task.kind === 'video'));
const held = memory.get(learningPrefix + 'lease:video');
assert(held && held.owner, '正常任务首先领取真实执行权');
memory.set(learningPrefix + 'lease:video', { ...held, owner: 'fixture-foreign-owner', sessionId: control.sessionId, taskId: task.id, expires: Date.UTC(2026, 9, 18, 10) + 18000, pageUrl: targetURL });
await page.waitForFunction(() => YianAssistant.getLearningState().tasks.some(task => task.status === 'waitingLease'));
const playsDuringWait = await page.evaluate(() => qaMedia.plays);
await page.waitForTimeout(650);
assert.equal(await page.evaluate(() => qaMedia.plays), playsDuringWait, '有效外部执行权下不能重复发起播放');
await page.evaluate(() => { qaOffset += 19000; qaMedia.currentTime += 1; });
await page.waitForFunction(() => YianAssistant.getLearningState().tasks.some(task => task.kind === 'video' && task.status === 'playing'));
await page.evaluate(() => YianAssistant.flushDiagnostics());
await consoleTab();
assert.equal(await page.getByTestId('diagnostic-record').isChecked(), true);
const jsonText = await download('json', () => page.getByTestId('diagnostic-exportJson').click());
const first = JSON.parse(jsonText);
assert.equal(first.schema, 1); assert(Array.isArray(first.records) && first.records.length > 0);
const events = new Set(first.records.map(record => record.event));
for (const event of ['lease.wait', 'lease.expired', 'lease.acquired', 'lease.resumed', 'task.action']) assert(events.has(event), '完整工作流必须记录 ' + event);
const waitEntry = first.records.find(record => record.event === 'lease.wait');
assert.equal(waitEntry.details.owner, 'fixture-foreign-owner');
assert.equal(waitEntry.context.url, targetURL.split('?')[0]);
const textExport = await download('txt', () => page.getByTestId('diagnostic-exportText').click());
assert(textExport.includes('lease.wait') && textExport.includes('lease.resumed'));
await page.screenshot({ path: path.join(qaDir, '01-desktop-diagnostics.png'), fullPage: false });
console.log('PASS 完整bundle真实执行权等待→19秒后恢复→JSON/TXT下载,记录动作及执行权事件并移除URL查询和题目正文');
// A manual reload starts paused, reads the persisted records, and keeps all
// previous event identities available for diagnosis.
const previousIds = first.records.map(record => record.id);
await page.reload(); await install(19000); await consoleTab();
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().status), 'paused');
const reloaded = JSON.parse(await download('json', () => page.evaluate(() => YianAssistant.exportDiagnostics('json'))));
const reloadedIds = new Set(reloaded.records.map(record => record.id));
for (const id of previousIds) assert(reloadedIds.has(id), '刷新后保留原诊断记录 ' + id);
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), 1);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().tasks.length), 1);
console.log('PASS 手动刷新后任务暂停,持久诊断与题库、任务检查点均保留');
// Exercise the rendered recording switch, rather than monkeypatching a
// logger. Actual pause/resume operations then produce no new records.
await page.getByTestId('diagnostic-record').uncheck();
await page.waitForFunction(() => YianAssistant.getDiagnosticState().enabled === false);
await page.evaluate(() => YianAssistant.flushDiagnostics());
const countWhenOff = await page.evaluate(() => YianAssistant.getDiagnosticState().total);
await page.evaluate(async () => { await YianAssistant.taskAction('resume'); await YianAssistant.taskAction('pause'); });
await page.waitForTimeout(700); await page.evaluate(() => YianAssistant.flushDiagnostics());
assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().total), countWhenOff);
assert.equal(await page.getByTestId('diagnostic-record').isChecked(), false);
assert((await page.getByTestId('diagnostic-status').textContent()).includes('已停止新增记录'));
await page.setViewportSize({ width: 390, height: 844 });
await page.getByTestId('diagnostic-exportText').scrollIntoViewIfNeeded();
const panelBox = await page.locator('#yian-question-assistant').boundingBox();
assert(panelBox && panelBox.x >= -1 && panelBox.x + panelBox.width <= 391, '390px宽下诊断面板保持在视口内');
await page.screenshot({ path: path.join(qaDir, '02-mobile-diagnostics.png'), fullPage: false });
console.log('PASS 诊断记录开关关闭后真实任务状态变更不再新增日志,390px操作可用');
const checkpointBefore = structuredClone(protectedStorage());
const stateBefore = await page.evaluate(() => ({ tasks: YianAssistant.getLearningState().tasks, total: YianAssistant.getSnapshot().total }));
assert(checkpointBefore.some(([key]) => key.endsWith('session.v1')), '测试必须包含已保存任务检查点');
await page.getByTestId('diagnostic-clearSaved').click();
await page.waitForFunction(() => YianAssistant.getDiagnosticState().total === 0);
await page.evaluate(() => YianAssistant.flushDiagnostics());
assert.deepEqual(protectedStorage(), checkpointBefore, '清空诊断不能改写题库、学习任务或检查点');
assert.deepEqual(await page.evaluate(() => ({ tasks: YianAssistant.getLearningState().tasks, total: YianAssistant.getSnapshot().total })), stateBefore);
const cleared = JSON.parse(await download('json', () => page.evaluate(() => YianAssistant.exportDiagnostics('json'))));
assert.deepEqual(cleared.records, []);
await page.reload(); await install(19000); await consoleTab();
assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().enabled), false, '关闭记录设置跨刷新保留');
assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().total), 0, '清空记录不能被刷新旧缓存复活');
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), 1);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().tasks.length), 1);
console.log('PASS 清空已保存诊断只删除诊断,保留任务、题库、检查点及关闭设置');
assert.deepEqual(errors, []); assert.deepEqual(unexpectedRequests, []);
assert.equal(navigationRequests.length, 3, '初次打开与两次刷新全由route本地提供,零真实业务请求');
await context.close();
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });