/* Browser plugin not available. 完整 bundle 视频→真实模板课中题→平台完成;全部业务 URL 被本地响应拦截。 */ '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 source = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8'); const qaDir = process.env.YIAN_VIDEO_QA_DIR || path.join(os.tmpdir(), 'yianxue-video-workflow-qa'); fs.mkdirSync(qaDir, { recursive: true }); const fixtureURL = 'http://10.119.67.7/clientcn/course/video-workflow'; const course = '视频联动安全课程'; const fixture = `谊安学视频联动验证

视频学习与课中答题联动

独立模拟播放与网站判分,所有网络已拦截。

${course}
  • ${course}

    视频
    尚未完成
  • `; async function main() { const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const memory = new Map(), requests = [], blocked = [], errors = []; try { const context = await browser.newContext({ viewport: { width: 1366, height: 900 } }); await context.exposeBinding('__gmRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback); await context.exposeBinding('__gmWrite', (_, key, value) => { memory.set(key, structuredClone(value)); }); await context.route('**/*', route => { requests.push(route.request().url()); if (route.request().isNavigationRequest() && route.request().url() === fixtureURL) return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: fixture }); blocked.push(route.request().url()); return route.abort(); }); const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); }); await page.goto(fixtureURL); assert.equal(page.url(), fixtureURL); assert.equal(await page.title(), '谊安学视频联动验证'); await page.evaluate(() => { window.qaMedia = { currentTime: 10, duration: 100, ended: false, paused: true, playbackRate: 1, playCalls: 0, pauseCalls: 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.playCalls++; qaMedia.paused = false; }; video.pause = () => { qaMedia.pauseCalls++; qaMedia.paused = true; }; window.qaQuiz = { clicks: [], selected: [], submits: 0, correct: false, closes: 0, videoPausedAtSelection: false }; window.qaShowQuiz = () => { const pane = document.createElement('div'); pane.className = 'cdk-overlay-pane question-modal-pc'; const texts = ['任意操作', '切换 mPa 单位', '保持 MPa 单位并核对']; pane.innerHTML = '
    '; pane.insertAdjacentHTML('afterbegin', '

    允许答题次数:1次

    '); document.querySelector('.cdk-overlay-container').append(pane); const grade = () => { qaQuiz.submits++; qaQuiz.correct = JSON.stringify(qaQuiz.selected) === JSON.stringify(['保持 MPa 单位并核对']); pane.innerHTML = '
    '; pane.querySelector('button').addEventListener('click', () => { qaQuiz.closes++; pane.remove(); }); }; pane.querySelectorAll('.option-row').forEach((row, index) => row.addEventListener('click', () => { qaQuiz.clicks.push({ label: String.fromCharCode(65 + index), text: texts[index] }); qaQuiz.videoPausedAtSelection = qaMedia.paused; qaQuiz.selected = [texts[index]]; pane.querySelectorAll('.examtest_content_list_des').forEach((node, i) => node.classList.toggle('examtest_content_list_des_checked', i === index)); const footer = pane.querySelector('.modal-footer'); if (!footer.querySelector('button')) { const button = document.createElement('button'); button.type = 'button'; button.innerHTML = ''; button.addEventListener('click', grade); footer.append(button); } if (window.qaPauseAfterSelection) { qaPauseAfterSelection = false; YianAssistant.taskAction('pause'); } })); }; }); const sandbox = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);const GM_setValue=(key,value)=>window.__gmWrite(key,value);\n' + source + '\n})();'; await page.addScriptTag({ content: sandbox }); await page.evaluate(() => YianAssistant.ready); const host = page.locator('#yian-question-assistant'); await host.getByRole('button', { name: '展开题库助手', exact: true }).click(); await host.getByTestId('tab-bank').click(); await host.locator('input[type=file]').setInputFiles({ name: '视频课中题.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify([{ course, type: '单选', question: '压力达到 2 MPa 时应该怎样操作?', options: ['保持 MPa 单位并核对', '切换 mPa 单位', '任意操作'].map((text, index) => ({ label: String.fromCharCode(65 + index), text })), answer: 'A' }])) }); await page.waitForFunction(() => YianAssistant.getSnapshot().total === 1); assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().records[0].role), '', '不带岗位的统一题库可导入'); await page.evaluate(async () => { await YianAssistant.setLearningSetting('mode', 'videoQuiz'); await YianAssistant.setLearningSetting('rate', 2); await YianAssistant.setLearningSetting('answerInterval', 0); window.qaResume = { continued: 0, restarted: 0 }; const dialog = document.createElement('div'); dialog.className = 'modal-dialog dialog_box'; dialog.innerHTML = ''; const buttons = dialog.querySelectorAll('button'); buttons[0].onclick = () => { qaResume.restarted++; qaMedia.currentTime = 0; dialog.remove(); }; buttons[1].onclick = () => { qaResume.continued++; qaMedia.currentTime = 37.5; dialog.remove(); }; document.querySelector('.cdk-overlay-container').append(dialog); await YianAssistant.taskAction('start'); }); await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused && qaMedia.playbackRate === 2); assert.equal(await page.evaluate(() => YianAssistant.getLearningState().total), 1); assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0); assert.deepEqual(await page.evaluate(() => qaResume), { continued: 1, restarted: 0 }); assert.equal(await page.evaluate(() => qaMedia.currentTime), 37.5); console.log('PASS 完整 bundle:无岗位题库导入、videoQuiz 调度、独立播放器真实 2X 状态'); await page.evaluate(() => { window.qaVisibility = 'hidden'; Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => qaVisibility }); document.dispatchEvent(new Event('visibilitychange')); document.querySelector('video').pause(); }); await page.waitForFunction(() => YianAssistant.getLearningState().video.status === 'backgroundPaused'); const visibilityPlays = await page.evaluate(() => qaMedia.playCalls); await page.evaluate(() => { qaVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange')); }); await page.waitForFunction(() => !qaMedia.paused); assert.equal(await page.evaluate(() => qaMedia.playCalls), visibilityPlays + 1); assert.equal(await page.evaluate(() => qaMedia.currentTime), 37.5); console.log('PASS 精确续播弹窗选择继续播放;网站后台暂停后返回立即续播,保留37.5秒实际位置'); const beforeTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id })); for (const key of ['overview', 'video', 'exam', 'bank', 'console', 'settings', 'video']) await host.getByTestId('tab-' + key).click(); const afterTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id })); assert.deepEqual(afterTabs, beforeTabs, '切栏不得重新播放、暂停或领取新任务'); await page.evaluate(() => { qaShowQuiz(); }); await page.waitForFunction(() => YianAssistant.getLearningState().tasks[0].status === 'waiting'); assert.equal(await page.evaluate(() => qaQuiz.clicks.length + qaQuiz.submits + qaQuiz.closes), 0, '默认单次机会开关阻止选项/判分/关闭'); assert.match(await page.evaluate(() => YianAssistant.getLearningState().tasks[0].reason), /1 次/); await host.getByTestId('setting-skipSingleAttemptQuiz').uncheck(); await page.waitForFunction(() => !YianAssistant.getLearningState().skipSingleAttemptQuiz && YianAssistant.getLearningState().status === 'paused'); assert.equal(await page.evaluate(() => qaQuiz.clicks.length + qaQuiz.submits + qaQuiz.closes), 0, '关闭开关不立即作答,需明确继续'); await page.evaluate(async () => { document.querySelector('.question-modal-pc').remove(); await YianAssistant.taskAction('resume'); }); await page.waitForFunction(() => !qaMedia.paused); await host.getByTestId('global-pause').click(); await page.waitForFunction(() => YianAssistant.getLearningState().status === 'paused' && qaMedia.paused); await page.evaluate(async () => { await YianAssistant.setLearningSetting('stallSeconds', 1); await YianAssistant.setLearningSetting('retryDelaySeconds', 1); qaShowQuiz(); }); const pausedPlays = await page.evaluate(() => qaMedia.playCalls); await page.waitForTimeout(2400); assert.equal(await page.evaluate(() => qaMedia.playCalls), pausedPlays, '全局暂停后不能自动续播'); assert.equal(await page.evaluate(() => qaQuiz.clicks.length + qaQuiz.submits), 0, '暂停期间不操作课中题'); assert.equal(requests.length, 1, '暂停期间没有故障刷新或其他业务请求'); assert.equal(await page.locator('.question_box').count(), 1); await page.evaluate(async () => { await YianAssistant.setLearningSetting('stallSeconds', 60); await YianAssistant.setLearningSetting('retryDelaySeconds', 15); window.qaPauseAfterSelection = true; }); await host.getByTestId('global-pause').click(); await page.waitForFunction(() => qaQuiz.clicks.length === 1 && YianAssistant.getLearningState().status === 'paused'); await page.waitForTimeout(400); assert.equal(await page.evaluate(() => qaQuiz.submits), 0, '选中后暂停必须取消后续查看答案/提交'); assert.equal(await page.evaluate(() => qaMedia.paused), true); await host.getByTestId('global-pause').click(); await page.waitForFunction(() => qaQuiz.closes === 1 && !qaMedia.paused); const quiz = await page.evaluate(() => qaQuiz); assert.deepEqual(quiz.clicks, [{ label: 'C', text: '保持 MPa 单位并核对' }], '原库 A 按正确文字映射到当前 C,保留 MPa/mPa 大小写差异'); assert.deepEqual(quiz.selected, ['保持 MPa 单位并核对']); assert(quiz.videoPausedAtSelection, '课中答题前实际暂停播放器'); assert.equal(quiz.submits, 1); assert.equal(quiz.correct, true); assert.equal(quiz.closes, 1); assert.equal(await page.locator('.question_box,.answer_box,.question-modal-pc').count(), 0); assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0); assert(await page.evaluate(() => qaMedia.playCalls) > pausedPlays, '正常关闭课中题后继续播放器'); console.log('PASS 六栏不影响任务;全局暂停停止课中答题和恢复;继续后真实 portal 全文选中→查看答案→网站判正确→关闭→续播'); await page.evaluate(() => { qaMedia.currentTime = qaMedia.duration; qaMedia.ended = true; document.querySelector('.menu_courseware_left').textContent = '已学习90秒'; }); await page.waitForFunction(() => YianAssistant.getLearningState().tasks[0].status === 'waitingPlatform'); assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0, '播放到末尾但平台未确认时不得完成'); await host.getByTestId('tab-overview').click(); await page.screenshot({ path: path.join(qaDir, '01-waiting-platform.png') }); await page.evaluate(() => { document.querySelector('app-course-courseware-video').setAttribute('data-is-learned', 'true'); document.querySelector('.menu_courseware_left').textContent = '已学习100秒'; }); await page.waitForFunction(() => YianAssistant.getLearningState().completed === 1); assert.equal(await page.evaluate(() => YianAssistant.getLearningState().tasks[0].status), 'completed'); await page.waitForFunction(() => YianAssistant.getLearningState().status === 'finished'); await page.screenshot({ path: path.join(qaDir, '02-platform-completed.png') }); await host.getByTestId('tab-video').click(); assert(await host.getByTestId('pane-video').textContent().then(text => text.includes('已确认完成')), '视频栏必须同步平台已完成状态'); assert.equal(requests.length, 1); assert.deepEqual(blocked, []); assert.deepEqual(errors, []); assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0); await host.getByTestId('tab-video').click(); await page.screenshot({ path: path.join(qaDir, '03-playback-switches-desktop.png') }); await page.setViewportSize({ width: 390, height: 844 }); await host.getByTestId('setting-resumePlaybackOnReturn').scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(qaDir, '04-playback-switches-mobile.png') }); await host.getByTestId('setting-autoContinuePlayback').uncheck(); await host.getByTestId('setting-resumePlaybackOnReturn').uncheck(); await page.waitForFunction(() => !YianAssistant.getLearningState().autoContinuePlayback && !YianAssistant.getLearningState().resumePlaybackOnReturn); await page.reload(); await page.addScriptTag({ content: sandbox }); await page.evaluate(() => YianAssistant.ready); const restored = await page.evaluate(() => YianAssistant.getLearningState()); for (const key of ['autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz']) assert.equal(restored[key], false, key + ' 刷新保存'); assert.equal(restored.status, 'paused'); assert.deepEqual(errors, []); console.log('PASS 单次机会保护ON/OFF真实开关与显式继续、全部三开关保存/刷新不自启、桌面/390px窄屏'); console.log('PASS 视频末尾等待平台;明确 data-is-learned 后完成任务并更新面板;零业务网络/刷新、无控制台错误'); console.log('截图:' + qaDir); } finally { await browser.close(); } } main().catch(error => { console.error(error); process.exitCode = 1; });