/* Browser plugin not available. Complete bundle and independently modelled site DOM. * Saved Angular templates supplied the selectors; no archived site scripts run here. * All business routes are fulfilled locally and no learning record reaches the site. */ '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 = path.join(os.tmpdir(), 'yianxue-course-entry-qa'); fs.mkdirSync(qaDir, { recursive: true }); const base = 'http://10.119.67.7/clientcn/'; const courseURL = base + 'course/course-entry-workflow'; const style = ``; const cover = '
开始学习
'; function row(index, { complete = false, active = false, exam = false } = {}) { return '
  • ' + index + '.安全培训视频' + index + '

    ' + (exam ? '' : '') + '
  • '; } function documentHTML(body) { return '谊安学课程入口回归' + style + '

    课程入口与完成状态

    ' + body + '
    '; } function courseHTML(rows, initialCover = cover) { return documentHTML('
    入口联动安全课程
    学习时长:10分钟33秒
    ' + initialCover + '
      ' + rows + '
    '); } async function setup(browser, html, options = {}) { const memory = new Map(), requests = [], blocked = [], errors = []; let holdLoadingWrite = false, releaseWrite = null, writeHeld = false; 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', async (_, key, value) => { if (holdLoadingWrite && /learning:session\.v1$/.test(key) && value?.tasks?.some(task => task.status === 'loading')) { holdLoadingWrite = false; writeHeld = true; await new Promise(resolve => { releaseWrite = resolve; }); } memory.set(key, structuredClone(value)); }); await context.route('**/*', route => { requests.push(route.request().url()); if (route.request().isNavigationRequest() && route.request().url() === courseURL) return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: html }); 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(courseURL); assert.equal(page.url(), courseURL); assert.equal(await page.title(), '谊安学课程入口回归'); assert(await page.locator('main').innerText(), '页面有真实课程内容'); await page.evaluate(({ delay, persistentCover, requirement }) => { window.qaEntry = { coverClicks: 0, wrongClicks: 0, rowClicks: [], playCalls: 0, media: null, exams: 0 }; window.qaInstallVideo = () => { document.querySelector('.qa-player').innerHTML = ''; const media = { currentTime: 27, duration: 100, ended: false, paused: true, playbackRate: 1 }; qaEntry.media = media; const video = document.querySelector('video'); for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => media[key], set: value => { media[key] = value; } }); video.play = async () => { qaEntry.playCalls++; media.paused = false; }; video.pause = () => { media.paused = true; }; }; window.qaWireCover = () => { document.querySelectorAll('.course-courseware-playdefult > span').forEach(button => button.addEventListener('click', () => { qaEntry.coverClicks++; // The site's cover always defaults to the first item, even if already learned. document.querySelectorAll('.cn_courseware_img').forEach((row, index) => row.classList.toggle('active', index === 0)); if (!persistentCover) setTimeout(qaInstallVideo, delay); })); document.querySelectorAll('[data-unknown-start]').forEach(button => button.addEventListener('click', () => { qaEntry.wrongClicks++; })); }; qaWireCover(); document.querySelectorAll('.cn_courseware_img').forEach((node, index) => { node.addEventListener('click', event => { if (event.target.closest('.related_test')) return; qaEntry.rowClicks.push(index + 1); document.querySelectorAll('.cn_courseware_img').forEach(row => row.classList.toggle('active', row === node)); // Real li onSelect(item) calls toCourseware directly; it does not show a new cover. qaEntry.media = null; document.querySelector('.qa-player').innerHTML = '

    正在打开播放器

    '; if (!persistentCover) setTimeout(qaInstallVideo, delay); }); node.querySelector('.en_courseware_finished').addEventListener('mouseover', () => { const tip = document.querySelector('.completion-tooltip'); tip.hidden = false; tip.textContent = requirement || '1.观看视频进度100%' + (node.querySelector('.related_test') ? ' 2.完成考试答题,并且及格通过' : ''); }); node.querySelector('.en_courseware_finished').addEventListener('mouseout', () => { document.querySelector('.completion-tooltip').hidden = true; }); }); document.querySelectorAll('.related_test').forEach(button => button.addEventListener('click', event => { event.stopPropagation(); qaEntry.exams++; history.pushState({}, '', '/clientcn/exam/entry-linked'); document.querySelector('#site-view').innerHTML = '
    入口联动安全课程 已答0/1
    总考试次数:1 次
    开始答题
    '; document.querySelector('.examdetail_button').onclick = () => { qaEntry.wrongClicks++; }; })); window.qaComplete = index => { const node = document.querySelectorAll('.cn_courseware_img')[index - 1]; node.querySelector('.en_courseware_finished').classList.remove('en_courseware_noFinished'); node.querySelector('.en_courseware_finished').alt = '已完成'; }; }, { delay: options.delay == null ? 1100 : options.delay, persistentCover: options.persistentCover === true, requirement: options.requirement || '' }); 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); return { page, context, memory, requests, blocked, errors, holdLoadingWrite() { holdLoadingWrite = true; }, async waitForLoadingWrite() { for (let i = 0; i < 100 && !writeHeld; i++) await page.waitForTimeout(50); assert(writeHeld, '应捕获入口点击前等待中的真实GM存储写入'); }, releaseWrite() { releaseWrite?.(); }, async close() { assert.deepEqual(blocked, [], '测试不能访问真实业务接口或归档资源'); assert.deepEqual(errors, [], '浏览器无相关错误或警告'); assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0); await context.close(); } }; } async function configure(page, mode = 'videoQuiz') { await page.evaluate(async mode => { await YianAssistant.setLearningSetting('mode', mode); await YianAssistant.setLearningSetting('answerInterval', 0); }, mode); } async function waitFor(page, predicate, label, timeout = 12000) { await page.waitForFunction(predicate, null, { timeout }).catch(async error => { console.error(label, await page.evaluate(() => ({ entry: qaEntry, learning: YianAssistant.getLearningState() }))); throw error; }); } (async () => { const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); try { // The cover receives its click on the child span, exactly as onSelectDefult in the saved template. const entry = await setup(browser, courseHTML(row(1))); await configure(entry.page); await entry.page.waitForTimeout(800); assert.equal(await entry.page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '未授权开始前不点击封面/课程'); await entry.page.evaluate(() => YianAssistant.taskAction('start')); await waitFor(entry.page, () => qaEntry.coverClicks === 1 && qaEntry.media && !qaEntry.media.paused, '唯一开始学习入口应被正确点击'); await entry.page.waitForTimeout(2200); assert.equal(await entry.page.evaluate(() => qaEntry.coverClicks), 1, '播放器异步出现与重复tick不得重复点封面'); assert.deepEqual(await entry.page.evaluate(() => qaEntry.rowClicks), [], '可用的默认封面入口不重复点目录项'); await entry.close(); console.log('PASS 完整bundle:初始无active时唯一真实封面span点击一次,异步播放器出现并正常播放'); const flow = await setup(browser, courseHTML(row(1, { complete: true }) + row(2) + row(3))); const { page } = flow; await configure(page); await page.waitForTimeout(800); assert.equal(await page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '未授权开始前不点击封面/课程'); await page.evaluate(() => YianAssistant.taskAction('start')); await waitFor(page, () => qaEntry.rowClicks.includes(2) && qaEntry.media && !qaEntry.media.paused, '首课件完成时应选第二目录项'); assert(!await page.evaluate(() => qaEntry.rowClicks.includes(1)), '平台已完成课件不能重开'); assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 1); await page.waitForTimeout(2200); assert.equal(await page.evaluate(() => qaEntry.coverClicks), 0, '默认封面会打开已完成的第一项,必须直接点击第二目录项'); assert.deepEqual(await page.evaluate(() => qaEntry.rowClicks), [2], '播放器异步出现时不得重复点击目录'); const host = page.locator('#yian-question-assistant'); await host.getByRole('button', { name: '展开题库助手', exact: true }).click(); await host.getByTestId('tab-overview').click(); await page.screenshot({ path: path.join(qaDir, '01-cover-started-desktop.png') }); await page.evaluate(() => qaComplete(2)); await waitFor(page, () => qaEntry.rowClicks.includes(3) && qaEntry.media && !qaEntry.media.paused && document.querySelectorAll('.cn_courseware_img')[2].classList.contains('active'), '完成视频二后应自动到视频三'); assert.equal(await page.evaluate(() => qaEntry.rowClicks.filter(index => index === 3).length), 1, '下一课件只点击一次'); assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 2); await page.evaluate(() => qaComplete(3)); await waitFor(page, () => YianAssistant.getLearningState().status === 'finished' && YianAssistant.getLearningState().completed === 3, '全部课件完成应结束当前课件清单'); assert.equal(await page.evaluate(() => qaEntry.coverClicks), 0); assert.deepEqual(await page.evaluate(() => qaEntry.rowClicks.filter(index => index === 1)), []); assert.equal(flow.requests.length, 1, '没有已知下课程入口时不能猜测新网址'); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: path.join(qaDir, '02-finished-mobile.png') }); await flow.close(); console.log('PASS 完整bundle:首项平台完成时直接选择未完成目录,播放器异步只开一次→完成后二到三→全部完成跳过;390px/桌面截图与零业务网络'); const paused = await setup(browser, courseHTML(row(1)), { persistentCover: true }); await configure(paused.page); await paused.page.evaluate(() => YianAssistant.taskAction('start')); await waitFor(paused.page, () => qaEntry.coverClicks === 1, '封面首次点击'); await paused.page.evaluate(() => YianAssistant.taskAction('pause')); await paused.page.waitForTimeout(1800); assert.equal(await paused.page.evaluate(() => qaEntry.coverClicks), 1, '全局暂停不能再点开始学习'); await paused.page.evaluate(() => { qaInstallVideo(); }); await paused.page.waitForTimeout(1400); assert.equal(await paused.page.evaluate(() => qaEntry.playCalls), 0, '暂停后迟到播放器不应启动'); await paused.page.evaluate(() => YianAssistant.taskAction('resume')); await waitFor(paused.page, () => qaEntry.playCalls === 1, '明确继续后才播放迟到播放器'); await paused.close(); console.log('PASS 全局暂停使封面流程停止,异步迟到播放器不播放,明确继续后核验再播放'); const partial = await setup(browser, courseHTML(row(1)), { persistentCover: true }); await configure(partial.page); await partial.page.evaluate(() => YianAssistant.taskAction('start')); await waitFor(partial.page, () => qaEntry.coverClicks === 1, '局部暂停前封面首次点击'); await partial.page.evaluate(() => YianAssistant.taskAction('videoPause')); await partial.page.evaluate(() => qaInstallVideo()); await partial.page.waitForTimeout(1700); assert.equal(await partial.page.evaluate(() => qaEntry.playCalls), 0, '视频局部暂停期间不启动迟到播放器'); assert.equal(await partial.page.evaluate(() => qaEntry.coverClicks), 1); await partial.close(); console.log('PASS 视频局部暂停也阻止异步播放器启动和封面重试'); const localPending = await setup(browser, courseHTML(row(1))); await configure(localPending.page); localPending.holdLoadingWrite(); await localPending.page.evaluate(() => { window.qaPendingStart = YianAssistant.taskAction('start'); }); await localPending.waitForLoadingWrite(); await localPending.page.evaluate(() => YianAssistant.taskAction('videoPause')); localPending.releaseWrite(); await localPending.page.evaluate(() => qaPendingStart); await localPending.page.waitForTimeout(700); assert.equal(await localPending.page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '入口存储写入期间局部暂停,迟到的旧动作不得点击封面或目录'); assert.equal(await localPending.page.evaluate(() => YianAssistant.getLearningState().videoPaused), true); await localPending.page.evaluate(() => YianAssistant.taskAction('videoResume')); await waitFor(localPending.page, () => qaEntry.coverClicks === 1 && qaEntry.media && !qaEntry.media.paused, '局部继续须重新领取任务后正常点击真实封面'); await localPending.page.waitForTimeout(1200); assert.equal(await localPending.page.evaluate(() => qaEntry.coverClicks), 1, '局部继续后仅点击封面一次'); assert.deepEqual(await localPending.page.evaluate(() => qaEntry.rowClicks), []); await localPending.close(); console.log('PASS GM加载写入等待时局部暂停取消旧点击,局部继续重新领取后真实封面只点一次并播放'); for (const [name, badCover] of [['未知按钮', ''], ['多个封面按钮', cover + cover]]) { const bad = await setup(browser, courseHTML(row(1), badCover)); await configure(bad.page); await bad.page.evaluate(() => YianAssistant.taskAction('start')); await bad.page.waitForTimeout(2200); assert.equal(await bad.page.evaluate(() => qaEntry.coverClicks + qaEntry.wrongClicks), 0, name + '必须等待人工确认,不能按文案泛点'); await bad.close(); } console.log('PASS 未知开始学习按钮与多个真实候选都不猜点'); const multiRows = row(1, { exam: true }).replace('

    ', '

    '); const multi = await setup(browser, courseHTML(multiRows)); const related = await multi.page.evaluate(() => YianAssistant.getLearningState().tasks.filter(task => task.kind === 'exam')); assert.deepEqual(related.map(task => task.title), ['入口联动安全课程', '另一份安全考核'], '同一课件两个实际关联考试都需保留,不能在子序号加入前被基础id去重'); assert.equal(new Set(related.map(task => task.id)).size, 2, '每个关联考试入口有独立任务身份'); assert.equal(await multi.page.evaluate(() => qaEntry.exams + qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '只识别多个入口不自动启动'); await multi.close(); console.log('PASS 同一课件多个实际关联考试分别识别,不遗漏、不重复、不擅自开考'); const stale = await setup(browser, courseHTML(row(1)), { persistentCover: true }); await configure(stale.page); stale.holdLoadingWrite(); await stale.page.evaluate(() => { window.qaPendingStart = YianAssistant.taskAction('start'); }); await stale.waitForLoadingWrite(); await stale.page.evaluate(() => { const old = document.querySelector('app-course'); const replacement = old.cloneNode(true); replacement.querySelector('.course-courseware-playdefult > span').onclick = () => { qaEntry.wrongClicks++; }; old.replaceWith(replacement); }); stale.releaseWrite(); await stale.page.evaluate(() => qaPendingStart); await stale.page.evaluate(() => YianAssistant.taskAction('pause')); await stale.page.waitForTimeout(700); assert.equal(await stale.page.evaluate(() => qaEntry.coverClicks + qaEntry.wrongClicks), 0, '同URL同标题的课程根已替换,旧动作不能点击已分离按钮或新封面'); await stale.close(); console.log('PASS 异步存储写入期间同URL同标题的课程根被替换,旧计划不点击迟到封面'); const reordered = await setup(browser, courseHTML(row(1) + row(2)), { persistentCover: true }); await configure(reordered.page); reordered.holdLoadingWrite(); await reordered.page.evaluate(() => { window.qaPendingStart = YianAssistant.taskAction('start'); }); await reordered.waitForLoadingWrite(); await reordered.page.evaluate(() => { const list = document.querySelector('ol'); list.append(list.firstElementChild); }); reordered.releaseWrite(); await reordered.page.evaluate(() => qaPendingStart); await reordered.page.evaluate(() => YianAssistant.taskAction('pause')); assert.equal(await reordered.page.evaluate(() => qaEntry.coverClicks + qaEntry.rowClicks.length), 0, '同URL同标题同根的目录顺序已变化,旧索引不能点击封面默认项或错行'); await reordered.close(); console.log('PASS 异步加载期间目录重排,仍在文档中的旧按钮也不按过期索引点击'); for (const mode of ['all', 'videoQuiz']) { const combined = await setup(browser, courseHTML(row(1, { exam: true }))); await configure(combined.page, mode); await combined.page.evaluate(async () => { const records = [{ course: '入口联动安全课程', type: '单选', question: '课后核验题', options: [{ label: 'A', text: '准确措施' }, { label: 'B', text: '错误措施' }], answer: 'A' }]; const bytes = new TextEncoder().encode(JSON.stringify(records)); await YianAssistant.importFiles([{ name: '入口联动题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]); await YianAssistant.taskAction('start'); }); await waitFor(combined.page, () => qaEntry.media && !qaEntry.media.paused, '组合完成条件前视频应正常播放'); assert.equal(await combined.page.evaluate(() => qaEntry.exams), 0, '未达到视频进度要求前不进入考试'); await combined.page.evaluate(() => { qaEntry.media.currentTime = 100; qaEntry.media.ended = true; }); if (mode === 'all') { await waitFor(combined.page, () => qaEntry.exams === 1 && location.pathname === '/clientcn/exam/entry-linked', '视频达到要求后应转同课件实际关联考试'); await waitFor(combined.page, () => YianAssistant.getLearningState().tasks.some(task => task.kind === 'exam' && task.status === 'waiting'), '单次机会保护应停在真实考试详情'); const state = await combined.page.evaluate(() => YianAssistant.getLearningState()); assert.equal(state.tasks.find(task => task.kind === 'video').status, 'waitingExam', '橙色图标仍需考试,不能把整个课件当作平台已完成'); assert.equal(state.tasks.find(task => task.kind === 'video').progress < 1 || state.video.platformComplete === false, true, '进度满只表示视频阶段,不能伪报平台完成'); assert.notEqual(state.status, 'finished'); assert.equal(await combined.page.evaluate(() => qaEntry.wrongClicks), 0, '仅一次机会交给人工,不点击开始答题'); } else { await combined.page.waitForTimeout(2400); assert.equal(await combined.page.evaluate(() => qaEntry.exams), 0, 'videoQuiz模式不能扩大授权自动进入课程考试'); const state = await combined.page.evaluate(() => YianAssistant.getLearningState()); assert.notEqual(state.status, 'finished', '橙图标不能误记完成'); assert.equal(state.video.platformComplete, false); assert.match(state.tasks.find(task => task.kind === 'video').reason, /考试|模式|完成条件/); } await combined.close(); } console.log('PASS 真实悬浮完成条件:进度满但仍需考试时all让出至唯一关联考试且等待绿图标;videoQuiz保持人工等待,不误报已完成'); const timed = await setup(browser, courseHTML(row(1, { exam: true })), { requirement: '1.观看课件时长达到1分钟 2.完成考试答题' }); await configure(timed.page, 'all'); await timed.page.evaluate(async () => { const records = [{ course: '入口联动安全课程', type: '单选', question: '时长要求核验', options: [{ label: 'A', text: '准确措施' }, { label: 'B', text: '错误措施' }], answer: 'A' }]; const bytes = new TextEncoder().encode(JSON.stringify(records)); await YianAssistant.importFiles([{ name: '时长要求题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]); await YianAssistant.taskAction('start'); }); await waitFor(timed.page, () => qaEntry.media && !qaEntry.media.paused, '时长要求下正常启动视频'); await timed.page.evaluate(() => { qaEntry.media.currentTime = 100; qaEntry.media.ended = true; }); await timed.page.waitForTimeout(2200); assert.equal(await timed.page.evaluate(() => qaEntry.exams), 0, '明确要求平台累计1分钟,视频ended不能取代平台27秒记录'); assert.equal(await timed.page.evaluate(() => YianAssistant.getLearningState().video.platformComplete), false); await timed.page.evaluate(() => { document.querySelector('.menu_courseware_left > div:last-child').textContent = '60秒'; }); await waitFor(timed.page, () => qaEntry.exams === 1, '平台累计达到明确时长后才进入实际关联考试'); assert.notEqual(await timed.page.evaluate(() => YianAssistant.getLearningState().status), 'finished', '时长达标仍等待考试及绿色整体完成标记'); await timed.close(); console.log('PASS 明确观看时长要求使用平台累计记录;仅ended不越过门槛,累计达到1分钟才转关联考试'); const guarded = await setup(browser, courseHTML(row(1, { exam: true })), { requirement: '1.观看课件时长达到1分钟 2.完成考试答题' }); await configure(guarded.page, 'all'); await guarded.page.evaluate(async () => { const records = [{ course: '入口联动安全课程', type: '单选', question: '课中核验题', options: [{ label: 'A', text: '准确措施' }, { label: 'B', text: '错误措施' }], answer: 'A' }]; const bytes = new TextEncoder().encode(JSON.stringify(records)); await YianAssistant.importFiles([{ name: '课中保护题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]); await YianAssistant.taskAction('start'); }); await waitFor(guarded.page, () => qaEntry.media && !qaEntry.media.paused, '保护场景视频先正常播放'); await guarded.page.evaluate(() => { const popup = document.createElement('div'); popup.className = 'question-modal-pc'; popup.innerHTML = '

    剩余答题次数:1次

    [单选]课中核验题

    A准确措施
    B错误措施
    '; popup.querySelectorAll('.examtest_content_list_des').forEach(option => { option.onclick = () => { qaEntry.wrongClicks++; }; }); document.body.append(popup); qaEntry.media.currentTime = 100; qaEntry.media.ended = true; document.querySelector('.menu_courseware_left > div:last-child').textContent = '60秒'; }); await waitFor(guarded.page, () => YianAssistant.getLearningState().tasks.some(task => task.kind === 'video' && task.status === 'waiting'), '仅一次课中题先交给人工'); await guarded.page.waitForTimeout(1200); assert.equal(await guarded.page.evaluate(() => qaEntry.exams), 0, '课中弹题存在时即使观看时长达标也不越过进入关联考试'); assert.equal(await guarded.page.evaluate(() => qaEntry.wrongClicks), 0, '一次机会保护无选项点击'); await guarded.page.evaluate(async () => { await YianAssistant.taskAction('pause'); document.querySelector('.question-modal-pc').remove(); }); await guarded.page.waitForTimeout(1200); assert.equal(await guarded.page.evaluate(() => qaEntry.exams), 0, '手动暂停优先,弹题消失和时长达标也不跳任务'); await guarded.page.evaluate(() => YianAssistant.taskAction('resume')); await waitFor(guarded.page, () => qaEntry.exams === 1, '人工处理课中题并明确继续后才能转实际关联考试'); await guarded.close(); console.log('PASS 观看达标时课中弹题和暂停优先;一次机会不操作,弹题处理后明确继续才转关联考试'); console.log('截图:' + qaDir); } finally { await browser.close(); } })().catch(error => { console.error(error); process.exitCode = 1; });