/* 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; });