/* 本机隔离浏览器学习调度回归;所有业务地址均被 route 拦截,无真实学习记录。 */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { chromium } = require('playwright'); const ZIP = require('../vendor/jszip.min.js'); const workspace = path.resolve(__dirname, '..'); const source = fs.readFileSync(path.join(workspace, 'learning-engine.js'), 'utf8'); const core = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8'); const origin = 'http://10.119.67.7/clientcn/'; const videoHTML = '
测试课程
  • 视频一

    视频
  • '; async function main() { const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const context = await browser.newContext(); const page = await context.newPage(); const errors = []; page.on('pageerror', e => errors.push(e.message)); await page.route('**/*', route => route.request().isNavigationRequest() ? route.fulfill({ status: 200, contentType: 'text/html', body: '' }) : route.abort()); await page.goto(origin + 'course/test'); await page.addScriptTag({ content: core }); await page.addScriptTag({ content: source }); let checks = 0; try { async function setup(html = videoHTML, config = {}, settings = {}) { await page.evaluate(async ({ html, config, settings }) => { window.qaEngine?.destroy(); history.replaceState(null, '', '/clientcn/course/test'); document.body.innerHTML = html; window.qaClock = 100000; window.qaMemory = new Map(); window.qaReloads = 0; window.qaNavigations = []; window.qaWorkerURLs = []; window.qaPlayCalls = 0; window.qaPauseCalls = 0; window.qaExamCalls = 0; window.qaQuizCalls = 0; window.qaVideo = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 }; window.qaExam = { stage: 'answering', total: 40, verifiedCount: 0 }; window.qaSnapshot = { document, url: location.href, accountId: 'fixture-account', bankVersion: 1, planId: 'fixture-plan', courses: ['测试课程'] }; window.qaStorage = { get: async key => qaMemory.has(key) ? JSON.parse(JSON.stringify(qaMemory.get(key))) : null, set: async (key, value) => { qaMemory.set(key, JSON.parse(JSON.stringify(value))); } }; window.qaInstallVideo = () => { const video = document.querySelector('video'); if (!video) return; for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => qaVideo[key], set: v => { qaVideo[key] = v; } }); video.play = async () => { qaPlayCalls++; if (settings.rejectPlay) throw new Error('autoplay'); qaVideo.paused = false; }; video.pause = () => { qaPauseCalls++; qaVideo.paused = true; }; }; qaInstallVideo(); window.qaCreate = (extra = {}) => YianLearningEngine.create(Object.assign({ getSnapshot: () => Object.assign({}, qaSnapshot, { document, url: location.href }), getExamState: () => qaExam, onExamStart: async () => { qaExamCalls++; return settings.examNotReady ? false : true; }, onExamPause: () => {}, onState: state => { window.qaLastState = state; }, storage: qaStorage, now: () => qaClock, reload: async () => { qaReloads++; }, navigate: async url => { qaNavigations.push(url); }, openWorker: async url => { qaWorkerURLs.push(url); return settings.blockWorker ? false : {}; }, solveQuiz: settings.noQuizAdapter ? undefined : async () => { qaQuizCalls++; if (settings.solveQuiz) { document.querySelector('[data-learning-quiz],.question-modal-pc')?.remove(); return { status: 'completed', verified: true }; } return { status: 'waiting', reason: 'fixture无精确答案' }; } }, extra)); window.qaEngine = qaCreate(); await qaEngine.configure(config); }, { html, config, settings }); } async function read() { return page.evaluate(() => qaEngine.getState()); } async function tick(delta = 0) { return page.evaluate(async delta => { qaClock += delta; return qaEngine.tick(); }, delta); } // 安全解析实际7页,只读取DOM,不执行任何归档脚本和媒体。 const samples = []; const archiveAvailable = fs.existsSync(path.join(workspace, '谊安学网页.zip')); if (archiveAvailable) { const zip = await ZIP.loadAsync(fs.readFileSync(path.join(workspace, '谊安学网页.zip'))); for (const file of Object.values(zip.files).filter(f => /\.html$/i.test(f.name))) samples.push({ name: file.name, html: await file.async('string') }); } else { // 仓库不提交原站ZIP;新克隆仍用最小DOM覆盖同一适配器。 samples.push({ name: 'fixture/01/page.html', html: '' + Array.from({ length: 26 }, (_, i) => '
    课程' + i + '
    必修课程
    ').join('') + '
    ' }); samples.push({ name: 'fixture/02/page.html', html: videoHTML.replace('测试课程', '1-1安全生产方针和目标').replace('', '') }); samples.push({ name: 'fixture/05/page.html', html: videoHTML.replace('已学习20秒', '已学习27秒') }); samples.push({ name: 'fixture/06/page.html', html: '
    测试课程 已答0/40
    ' }); } const inspected = await page.evaluate(samples => samples.map(({ name, html }) => { const doc = QuestionBankCore.parseHTML(html); const p = YianLearningEngine.describePage(doc, location.href); const tasks = YianLearningEngine.discoverTasks(doc, location.href); return { name, kind: p.kind, title: p.title, learned: p.learnedSeconds, complete: p.platformComplete, quiz: Boolean(p.quiz), tasks: tasks.map(t => ({ kind: t.kind, title: t.title, status: t.status })) }; }), samples); const map = inspected.find(x => x.name.includes('/01/')); assert.equal(map.tasks.length, 26, '实际26课程不能把9阶段当作任务数'); assert.equal(map.tasks.filter(t => t.kind === 'course').length, 26); const detail = inspected.find(x => x.name.includes('/02/')); assert.equal(detail.title, '1-1安全生产方针和目标'); assert.deepEqual(detail.tasks.map(t => t.kind), ['video', 'exam']); const played = inspected.find(x => x.name.includes('/05/')); assert.equal(played.complete, false, 'noFinished不能因共同class前缀误认为完成'); assert.equal(played.learned, 27); assert.equal(played.quiz, false); assert.equal(inspected.find(x => x.name.includes('/06/')).kind, 'exam'); checks++; const identities = await page.evaluate(() => { const d = new DOMParser().parseFromString('同名同名外站', 'text/html'); return YianLearningEngine.discoverTasks(d, location.href); }); assert.equal(identities.length, 2); assert.notEqual(identities[0].id, identities[1].id); checks++; const topics = await page.evaluate(() => { const d = new DOMParser().parseFromString('
    安全课程 已答0/40
    ', 'text/html'); return [YianLearningEngine.preflightTopic(d, location.href, { courses: [] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全课程'] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全'] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全领导力'] }, { 安全课程: ['安全领导力'] }), YianLearningEngine.preflightTopic(d, location.href, { records: [{ course: '安全课程' }] }, {})]; }); assert.deepEqual(topics.map(t => t.status), ['missing', 'matched', 'waiting', 'matched', 'matched']); checks++; await setup(videoHTML, { rate: 4, mode: 'videoQuiz' }); await page.evaluate(() => qaEngine.start()); let state = await read(); assert.equal(state.rate, 2); assert.equal(state.answerInterval, 4); assert.equal(state.video.actualRate, 2); assert.equal(state.total, 1); assert.equal(state.completed, 0); assert.equal(state.etaSeconds, 80); await page.evaluate(() => { qaVideo.currentTime = 100; qaVideo.ended = true; }); state = await tick(1000); assert.equal(state.tasks[0].status, 'waitingPlatform'); assert.equal(state.completed, 0); await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); await page.evaluate(() => document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true')); state = await tick(); assert.equal(state.completed, 1); assert.equal(state.tasks[0].status, 'completed'); checks++; await setup(); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.configure({ videoPaused: true })); await tick(); assert.equal(await page.evaluate(() => qaVideo.paused), true); await page.evaluate(() => qaEngine.resume()); assert.equal((await read()).videoPaused, false); await page.evaluate(() => qaEngine.pause()); const prior = await page.evaluate(() => qaPlayCalls); await tick(180000); assert.equal(await page.evaluate(() => qaPlayCalls), prior); assert.equal(await page.evaluate(() => qaReloads), 0); checks++; await setup(videoHTML + '

    强制题

    ', { mode: 'video' }); state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting'); await tick(180000); assert.equal(await page.evaluate(() => qaQuizCalls), 0); assert.equal(await page.evaluate(() => qaReloads), 0); checks++; await setup(videoHTML + '

    真实portal题

    ', { mode: 'videoQuiz' }, { solveQuiz: true }); await page.evaluate(() => qaEngine.start()); await tick(); assert.equal(await page.evaluate(() => qaQuizCalls), 1); assert.equal((await read()).tasks[0].status, 'playing'); checks++; await setup(videoHTML + '

    未知题

    ', { mode: 'videoQuiz' }); state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting'); assert.match(state.tasks[0].reason, /无精确答案/); await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++; await setup(); await page.evaluate(() => qaEngine.start()); await tick(60000); assert.equal(await page.evaluate(() => qaReloads), 0); assert.equal((await read()).tasks[0].status, 'retrying'); await tick(15000); assert.equal(await page.evaluate(() => qaReloads), 1); assert.equal((await read()).tasks[0].retryCount, 1); const recovery = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); qaEngine = qaCreate(); return qaEngine.tick(); }); assert.equal(recovery.status, 'running'); assert.equal(recovery.tasks[0].retryCount, 1); for (let i = 0; i < 3; i++) { await tick(60000); await tick(15000); } assert.equal(await page.evaluate(() => qaReloads), 3); assert.equal((await read()).tasks[0].status, 'failed'); checks++; await setup(); await page.evaluate(() => qaEngine.start()); state = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); qaEngine = qaCreate(); return qaEngine.tick(); }); assert.equal(state.status, 'paused'); assert.match(state.message, /人工刷新/); await page.evaluate(() => qaEngine.resume()); assert.equal((await read()).status, 'running'); await page.evaluate(() => { qaSnapshot.accountId = 'other-account'; }); state = await tick(); assert.equal(state.status, 'paused'); assert.match(state.message, /账号/); checks++; // 正常任务跳转有一次性授权;人工刷新同一地址没有该授权。 await setup('
    测试课程
    课程
    '); await page.evaluate(() => qaEngine.start()); state = await page.evaluate(async html => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); history.replaceState(null, '', '/clientcn/course/authorized-target'); document.body.innerHTML = html; qaInstallVideo(); qaEngine = qaCreate(); return qaEngine.tick(); }, videoHTML); assert.equal(state.status, 'running'); assert.equal(state.tasks.filter(t => t.kind === 'course')[0].status, 'expanded'); assert.equal(state.video.title, '视频一'); checks++; // 播放器内的用户主动暂停不触发卡顿恢复。 await setup(videoHTML); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => { qaClock += 1000; const v = document.querySelector('video'); v.style.cssText = 'width:200px;height:100px'; v.onclick = () => { qaVideo.paused = true; v.dispatchEvent(new Event('pause', { bubbles: true })); }; }); await page.locator('video').click(); state = await tick(); assert.equal(state.tasks[0].status, 'waiting'); assert.match(state.tasks[0].reason, /主动暂停/); await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++; await setup(videoHTML, {}, { rejectPlay: true }); state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting'); await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++; const examDetail = '
    测试课程 已答0/40
    开始答题
    '; await setup(examDetail, { mode: 'exam' }); await page.evaluate(() => qaEngine.start()); assert.equal(await page.evaluate(() => qaEnterCount), 1); await page.evaluate(() => { document.body.innerHTML = '
    测试课程
    '; }); await tick(); assert.equal(await page.evaluate(() => qaExamCalls), 1); await page.evaluate(() => { qaExam = { stage: 'waiting', predictedScore: 80, message: '预计得分正好80分等待人工' }; }); state = await tick(); assert.equal(state.tasks[0].status, 'waiting'); await tick(600000); assert.equal(await page.evaluate(() => qaExamCalls), 1); assert.equal(await page.evaluate(() => qaReloads), 0); checks++; // 缺题改由用户在网站交卷,等待与局部暂停只读同步最终结果,不能重启控制器。 for (const locallyPaused of [false, true]) { await setup(examDetail, { mode: 'exam' }); await page.evaluate(() => { qaEngine.destroy(); window.qaExamPauseCalls = 0; qaEngine = qaCreate({ onExamPause: () => { qaExamPauseCalls++; qaExam = { stage: 'paused', message: '已交给用户处理' }; } }); }); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => { document.body.innerHTML = '
    测试课程
    '; }); await tick(); assert.equal(await page.evaluate(() => qaExamCalls), 1); await page.evaluate(() => { qaExam = { stage: 'waiting', predictedScore: 84, total: 40, verifiedCount: 32, requiresManualSubmission: true, enabled: false, message: '题目不全,请在网站手动确认交卷' }; }); state = await tick(); assert.equal(state.tasks[0].status, 'waiting'); if (locallyPaused) { await page.evaluate(() => qaEngine.configure({ examPaused: true })); assert.equal(await page.evaluate(() => qaExamPauseCalls), 1); } for (const stage of ['waiting', 'unknown', 'submitting']) { await page.evaluate(stage => { qaExam = { stage, submitted: true, confirmed: false, requiresManualSubmission: true, enabled: false }; }, stage); state = await tick(600000); assert.equal(state.tasks[0].status, 'waiting', '等待/未知/仅点击提交都不能当作网站确认完成'); assert.equal(state.completed, 0); assert.equal(await page.evaluate(() => qaExamCalls), 1); } await page.evaluate(flagOnly => { qaExam = flagOnly ? { stage: 'unknown', submitted: true, confirmed: true } : { stage: 'submitted', submitted: true, confirmed: true }; }, locallyPaused); state = await tick(); assert.equal(state.tasks[0].status, 'completed'); assert.equal(state.completed, 1); assert.equal(await page.evaluate(() => qaExamCalls), 1, '人工交卷完成只读同步,不得重新启动答题'); assert.equal(await page.evaluate(() => qaReloads), 0); assert.equal(await page.evaluate(() => qaNavigations.length), 0); checks++; } await setup('
    没有题库的考试
    开始答题
    ', { mode: 'exam' }); state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'skipped'); assert.equal(await page.evaluate(() => Boolean(window.qaWrongEnter)), false); checks++; await setup(videoHTML + '测试课程', { execution: 'parallel' }, { blockWorker: true }); state = await page.evaluate(() => qaEngine.start()); assert.equal(await page.evaluate(() => qaWorkerURLs.length), 1); assert.equal(state.tasks.find(t => t.kind === 'exam').status, 'waiting'); assert.match(state.tasks.find(t => t.kind === 'exam').reason, /拦截/); checks++; await setup(videoHTML + '测试课程', { execution: 'parallel' }); await page.evaluate(() => qaEngine.start()); const opened = await page.evaluate(() => qaWorkerURLs[0]); assert.match(opened, /__yian_worker=/); state = await tick(31000); assert.equal(state.tasks.find(t => t.kind === 'exam').status, 'waiting'); assert.match(state.tasks.find(t => t.kind === 'exam').reason, /未确认启动/); checks++; await setup(); await page.evaluate(() => qaEngine.start()); const exclusive = await page.evaluate(async () => { window.qaSecond = qaCreate(); await qaSecond.tick(); await qaSecond.resume(); return { first: qaEngine.getState(), second: qaSecond.getState(), plays: qaPlayCalls }; }); assert.equal(exclusive.plays, 1, '同类租约阻止第二页面重复播放'); assert.match(exclusive.second.tasks[0].reason, /另一个页面/); checks++; await setup(videoHTML, { manualConfirmIncomplete: false }); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause()); state = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); qaEngine = qaCreate(); return qaEngine.tick(); }); assert.equal(state.status, 'paused'); assert.equal(state.manualConfirmIncomplete, false, '单页检查点恢复关闭策略且保持暂停'); await page.evaluate(() => qaEngine.configure({ manualConfirmIncomplete: true })); assert.equal(await page.evaluate(async () => (await qaStorage.get('session.v1')).config.manualConfirmIncomplete), true); assert.equal((await read()).status, 'paused'); checks++; const stalePolicyRead = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); const previous = qaStorage; let release; let held; let holdNext = false; const gate = new Promise(resolve => { release = resolve; }); const captured = new Promise(resolve => { held = resolve; }); qaStorage = { get: async key => { const value = await previous.get(key); if (key === 'control.v1' && holdNext) { holdNext = false; held(); await gate; } return value; }, set: previous.set }; qaEngine = qaCreate(); await qaEngine.tick(); holdNext = true; const oldTick = qaEngine.tick(); await captured; await qaEngine.configure({ manualConfirmIncomplete: false }); release(); await oldTick; return { state: qaEngine.getState(), shared: await qaStorage.get('control.v1') }; }); assert.equal(stalePolicyRead.state.manualConfirmIncomplete, false, '迟到的旧共享读数不得覆盖刚发布的开关'); assert.equal(stalePolicyRead.shared.config.manualConfirmIncomplete, false); assert.equal(stalePolicyRead.state.status, 'paused'); checks++; // 两个实际标签通过同一GM式存储确认工作页,暂停信号同步,完全隔离真实业务网络。 const shared = new Map(); await context.exposeBinding('__learningGet', (_, key) => shared.has(key) ? JSON.parse(JSON.stringify(shared.get(key))) : null); await context.exposeBinding('__learningSet', (_, key, value) => { shared.set(key, JSON.parse(JSON.stringify(value))); }); const relatedHTML = videoHTML.replace('', ''); await setup(relatedHTML, { execution: 'parallel' }); await page.evaluate(async () => { qaEngine.destroy(); qaStorage = { get: key => __learningGet(key), set: (key, value) => __learningSet(key, value) }; qaEngine = qaCreate(); await qaEngine.configure({ execution: 'parallel' }); await qaEngine.start(); }); const workerURL = await page.evaluate(() => qaWorkerURLs[0]); const workerPage = await context.newPage(); try { await workerPage.route('**/*', route => route.request().isNavigationRequest() ? route.fulfill({ status: 200, contentType: 'text/html', body: '' + relatedHTML }) : route.abort()); await workerPage.goto(workerURL); await workerPage.addScriptTag({ content: source }); let workerState = await workerPage.evaluate(async examDetail => { window.fixtureExamCalls = 0; window.fixtureExamPause = 0; document.querySelector('.related_test').onclick = () => { document.body.innerHTML = examDetail; }; window.fixtureEngine = YianLearningEngine.create({ getSnapshot: () => ({ document, url: location.href, courses: ['测试课程'], accountId: 'fixture-account', bankVersion: 1, planId: 'fixture-plan' }), storage: { get: key => __learningGet(key), set: (key, value) => __learningSet(key, value) }, now: () => 100000, onExamStart: async () => { fixtureExamCalls++; return true; }, getExamState: () => window.fixtureExamState || ({ stage: 'answering', predictedScore: 84, verifiedCount: 32, total: 40, elapsedSeconds: 120, waitSeconds: 240 }), onExamPause: () => { fixtureExamPause++; } }); return fixtureEngine.tick(); }, examDetail); assert.equal(workerState.status, 'running'); assert.equal(workerState.tasks.find(t => t.kind === 'exam').status, 'loading'); assert.equal(await workerPage.evaluate(() => Boolean(document.querySelector('app-examdetail'))), true, '无href时从真实课程页点击原关联考试入口'); await workerPage.evaluate(() => { history.replaceState(null, '', '/clientcn/examtest/e1?flag=test'); document.body.innerHTML = '
    测试课程
    '; }); workerState = await workerPage.evaluate(() => fixtureEngine.tick()); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 1); state = await tick(); assert.match(state.message, /并行执行器已确认/); assert.equal(state.tasks.filter(t => t.kind === 'exam').length, 1, '详情转试卷不得复制同一考试'); assert.equal(state.exam.predictedScore, 84); assert.equal(state.exam.verified, 32); assert.equal(state.etaSeconds, 240, '并行总览显示另一场考试证据和剩余时间'); await page.evaluate(() => qaEngine.pause()); workerState = await workerPage.evaluate(() => fixtureEngine.tick()); assert.equal(workerState.status, 'paused'); assert.ok(await workerPage.evaluate(() => fixtureExamPause) > 0); await page.evaluate(() => qaEngine.configure({ manualConfirmIncomplete: false })); workerState = await workerPage.evaluate(() => fixtureEngine.tick()); state = await tick(); assert.equal(state.manualConfirmIncomplete, false); assert.equal(workerState.manualConfirmIncomplete, false, '主页面关闭开关同步工作页'); assert.equal(state.status, 'paused'); assert.equal(workerState.status, 'paused'); await workerPage.evaluate(() => fixtureEngine.configure({ manualConfirmIncomplete: true })); state = await tick(); workerState = await workerPage.evaluate(() => fixtureEngine.tick()); assert.equal(state.manualConfirmIncomplete, true, '工作页开启开关反向同步主页面'); assert.equal(workerState.manualConfirmIncomplete, true); assert.equal(state.status, 'paused'); assert.equal(workerState.status, 'paused', '策略同步不能自动恢复任务'); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 1, '修改共享策略期间不得重启答题'); const synchronizedControl = shared.get('control.v1'); assert.equal(synchronizedControl.status, 'paused'); assert.equal(synchronizedControl.config.manualConfirmIncomplete, true); assert.equal(shared.get('session.v1').config.manualConfirmIncomplete, true, '主页面暂停时同步保存工作页策略'); checks++; await page.evaluate(() => qaEngine.resume()); await workerPage.evaluate(() => fixtureEngine.tick()); workerState = await workerPage.evaluate(() => fixtureEngine.tick()); assert.equal(workerState.status, 'running'); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 2, '全局继续后重新核验考试'); await workerPage.evaluate(() => fixtureEngine.pause()); state = await tick(); assert.equal(state.status, 'paused', '工作页的全局暂停同步主页面'); await workerPage.evaluate(() => fixtureEngine.resume()); await tick(); state = await tick(); assert.equal(state.status, 'running'); await page.evaluate(() => document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true')); state = await tick(); assert.equal(state.completed, 1); assert.equal(state.status, 'running', '视频先完成仍等待另一页考试'); await workerPage.evaluate(() => { window.fixtureExamState = { stage: 'submitted', predictedScore: 84, total: 40, verifiedCount: 32 }; }); await workerPage.evaluate(() => fixtureEngine.tick()); state = await tick(); assert.equal(state.completed, 2); assert.equal(state.status, 'finished', '本地任务完成后仍合并远端终态'); await page.evaluate(() => qaEngine.start()); workerState = await workerPage.evaluate(() => fixtureEngine.tick()); // 旧执行器已完成,不会因新一轮开始重新答题。 assert.ok(['finished', 'paused'].includes(workerState.status)); checks++; } finally { await workerPage.close(); } await setup(); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => { for (let i = 0; i < 520; i++) qaEngine.log('info', '日志' + i); }); assert.equal((await read()).logs.length, 500); await page.evaluate(() => qaEngine.clearLogs()); state = await read(); assert.equal(state.logs.length, 0); assert.equal(state.tasks.length, 1); checks++; await setup(); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause()); const initializedRace = await page.evaluate(async () => { qaEngine.destroy(); const previous = qaStorage; let release; const gate = new Promise(resolve => { release = resolve; }); qaStorage = { get: async key => { if (key === 'session.v1') await gate; return previous.get(key); }, set: previous.set }; qaEngine = qaCreate(); const first = qaEngine.tick(); const update = qaEngine.configure({ rate: 2, mode: 'video' }); await new Promise(r => setTimeout(r, 10)); release(); await Promise.all([first, update]); return qaEngine.getState(); }); assert.equal(initializedRace.rate, 2); assert.equal(initializedRace.mode, 'video'); checks++; // 长时间课中题异步流程也须续租,超过18秒后仍只有原执行器操作。 await setup(videoHTML + '

    异步核验题

    ', { mode: 'videoQuiz' }); await page.evaluate(() => { qaEngine.destroy(); window.qaQuizCommitted = 0; qaEngine = qaCreate({ solveQuiz: async () => { await new Promise(resolve => { window.qaReleaseQuiz = resolve; }); if (qaEngine.getState().status !== 'running') return { status: 'waiting', reason: '任务已失效' }; qaQuizCommitted++; document.querySelector('[data-learning-quiz]').remove(); return { status: 'completed', verified: true }; } }); window.qaHeldStart = qaEngine.start(); }); await page.waitForFunction(() => typeof qaReleaseQuiz === 'function'); await tick(10000); await tick(10000); const heldLease = await page.evaluate(async () => ({ lease: await qaStorage.get('lease:video'), clock: qaClock, status: qaEngine.getState().status })); assert.equal(heldLease.status, 'running'); assert.ok(heldLease.lease.expires > heldLease.clock, '异步题超过18秒仍续租'); await page.evaluate(async () => { qaReleaseQuiz(); await qaHeldStart; }); assert.equal(await page.evaluate(() => qaQuizCommitted), 1); checks++; // 租约被另一执行器取得后,旧异步题不得继续提交,也不得清除新租约。 await setup(videoHTML + '

    异步核验题

    ', { mode: 'videoQuiz' }); await page.evaluate(() => { qaEngine.destroy(); window.qaReleaseQuiz = null; window.qaQuizCommitted = 0; qaEngine = qaCreate({ solveQuiz: async () => { await new Promise(resolve => { window.qaReleaseQuiz = resolve; }); if (qaEngine.getState().status !== 'running') return { status: 'waiting', reason: '任务已失效' }; qaQuizCommitted++; return { status: 'completed', verified: true }; } }); window.qaHeldStart = qaEngine.start(); }); await page.waitForFunction(() => typeof qaReleaseQuiz === 'function'); await page.evaluate(async () => { const lease = await qaStorage.get('lease:video'); lease.owner = 'another-worker'; lease.expires = qaClock + 18000; await qaStorage.set('lease:video', lease); }); state = await tick(1000); assert.equal(state.status, 'paused'); assert.match(state.message, /租约/); assert.equal(await page.evaluate(() => qaVideo.paused), true); await page.evaluate(async () => { qaReleaseQuiz(); await qaHeldStart; }); assert.equal(await page.evaluate(() => qaQuizCommitted), 0); assert.equal(await page.evaluate(async () => (await qaStorage.get('lease:video')).owner), 'another-worker'); checks++; assert.deepEqual(errors, []); console.log('learning-engine:', checks, 'checks passed (' + (archiveAvailable ? '真实归档DOM' : '合成DOM(未提供原站归档)') + '、平台完成、课中题、恢复、主题预检、租约、并行确认)'); } finally { await context.close(); await browser.close(); } } main().catch(error => { console.error(error); process.exitCode = 1; });