/* Isolated multi-page navigation and retry regression. All site requests are * fulfilled locally; the archived site's scripts and business APIs never run. */ 'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { chromium } = require('playwright'); const source = fs.readFileSync(path.join(__dirname, '..', 'learning-engine.js'), 'utf8'); const origin = 'http://10.119.67.7/clientcn/'; async function main() { const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true }); const context = await browser.newContext(); const blocked = [], errors = []; await context.route('**/*', route => { if (route.request().isNavigationRequest() && route.request().url().startsWith(origin)) return route.fulfill({ status: 200, contentType: 'text/html', body: '' }); blocked.push(route.request().url()); return route.abort(); }); const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); await page.goto(origin + 'learningmap/navigation-fixture'); await page.addScriptTag({ content: source }); let checks = 0; try { await page.evaluate(async () => { window.qaClock = 100000; window.qaMemory = new Map(); window.qaRequests = []; window.qaPendingNavigation = ''; window.qaCourseClicks = [0, 0]; window.qaPlayCalls = 0; window.qaMapURL = location.href; window.qaStorage = { get: async key => qaMemory.has(key) ? structuredClone(qaMemory.get(key)) : null, set: async (key, value) => { qaMemory.set(key, structuredClone(value)); } }; window.qaRenderMap = () => { document.body.innerHTML = '' + ['一', '二'].map((name, index) => '
').join('') + '
'; document.querySelectorAll('.map_activity_content_info_top').forEach((node, index) => { node.onclick = () => { qaCourseClicks[index]++; history.pushState({}, '', '/clientcn/course/map-course-' + index); qaRenderCourse(index); }; }); }; window.qaRenderCourse = index => { document.body.innerHTML = '
课程' + (index ? '二' : '一') + '
  • 视频' + (index ? '二' : '一') + '

    视频
  • '; const media = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 }; const video = document.querySelector('video'); for (const key of Object.keys(media)) Object.defineProperty(video, key, { configurable: true, get: () => media[key], set: value => { media[key] = value; } }); video.play = async () => { qaPlayCalls++; media.paused = false; }; video.pause = () => { media.paused = true; }; }; window.qaFlushNavigation = () => { if (!qaPendingNavigation) return false; history.pushState({}, '', qaPendingNavigation); qaPendingNavigation = ''; qaRenderMap(); return true; }; qaRenderMap(); window.qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock, getSnapshot: () => ({ document, url: location.href, planId: 'navigation-fixture', accountId: 'fixture-account' }), navigate: async url => { qaRequests.push(url); qaPendingNavigation = url; } }); await qaEngine.configure({ mode: 'video', execution: 'sequential' }); await qaEngine.start(); }); assert.equal(await page.evaluate(() => qaCourseClicks[0]), 1, '地图原始课程入口被点击一次'); await page.evaluate(() => qaEngine.tick()); const beforeReturn = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, requests: qaRequests, pending: qaPendingNavigation })); assert.equal(beforeReturn.requests[0], origin + 'learningmap/navigation-fixture', '下一课程无 href 时返回访问过的原地图地址'); assert.equal(beforeReturn.pending, beforeReturn.requests[0]); assert.match(beforeReturn.url, /\/course\/map-course-0$/, '异步导航生效前仍显示旧课程'); assert.equal(beforeReturn.state.tasks.find(task => task.title === '视频一').status, 'completed', '课程一的实际平台完成状态被识别'); checks++; for (let i = 0; i < 3; i++) await page.evaluate(() => qaEngine.tick()); const stale = await page.evaluate(() => ({ tasks: qaEngine.getState().tasks, clicks: qaCourseClicks })); const secondCourse = stale.tasks.find(task => task.kind === 'course' && task.title === '课程二'); assert(secondCourse, '保留下一课程任务'); assert(!['expanded', 'completed'].includes(secondCourse.status), '旧课程仍显示时不能误展开或完成下一课程'); assert(!stale.tasks.some(task => task.parentCourse === secondCourse.id), '旧课程课件不能归到下一课程'); assert.deepEqual(stale.clicks, [1, 0], '未到地图前不点击错误课程'); checks++; assert.equal(await page.evaluate(() => qaFlushNavigation()), true); for (let i = 0; i < 8; i++) { await page.evaluate(() => qaEngine.tick()); if (await page.evaluate(() => qaEngine.getState().video?.title === '视频二')) break; } const next = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, clicks: qaCourseClicks, playCalls: qaPlayCalls })); assert.deepEqual(next.clicks, [1, 1], '回地图后自动点击第二课程一次'); assert.equal(next.url, origin + 'course/map-course-1'); assert.equal(next.state.video?.title, '视频二', '第二课程的实际视频自动进入调度'); assert.equal(next.state.video?.status, 'playing'); assert(next.playCalls >= 1); assert.equal(next.state.tasks.filter(task => task.title === '视频一').length, 1, '回地图不会重复添加旧课件'); checks++; // A timed-out task can be retried explicitly. The retry must click the real // cover span again and receive a fresh loading window, without resetting // platform progress or the bounded automatic-recovery counter. await page.evaluate(async () => { qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); qaMemory.clear(); history.replaceState({}, '', '/clientcn/course/retry-loading-fixture'); document.body.innerHTML = '
    重试课程
    开始学习
  • 重试视频

    视频
  • '; window.qaCoverClicks = 0; document.querySelector('.course-courseware-playdefult > span').onclick = () => { qaCoverClicks++; }; qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock, getSnapshot: () => ({ document, url: location.href, accountId: 'fixture-account' }) }); await qaEngine.configure({ mode: 'video' }); await qaEngine.start(); }); assert.equal(await page.evaluate(() => qaCoverClicks), 1, '首次正常点击实际封面 span'); await page.evaluate(async () => { qaClock += 16001; await qaEngine.tick(); }); assert.equal(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.kind === 'video').status), 'waiting'); const retry = await page.evaluate(async () => { await qaEngine.resume(); return { clicks: qaCoverClicks, state: qaEngine.getState() }; }); assert.equal(retry.clicks, 2, '明确继续后再次点击真实封面入口'); assert.notEqual(retry.state.tasks.find(task => task.kind === 'video').status, 'waiting', '旧 openedAt 不能让新重试立即超时'); await page.evaluate(async () => { qaClock += 14999; await qaEngine.tick(); }); assert.notEqual(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.kind === 'video').status), 'waiting', '本次加载有独立的 15 秒等待窗口'); await page.evaluate(async () => { qaClock += 1002; await qaEngine.tick(); }); assert.equal(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.kind === 'video').status), 'waiting'); assert.equal(await page.evaluate(() => qaCoverClicks), 2, '超时期间不循环点击封面'); checks++; // Display names are not resource identity. While navigation back to the // map is pending, the old course has exactly the same name as the next. await page.evaluate(async () => { qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); qaMemory.clear(); history.replaceState({}, '', '/clientcn/learningmap/same-title-fixture'); window.qaSameMapURL = location.href; window.qaSamePending = ''; window.qaSameClicks = [0, 0]; window.qaRenderSameMap = () => { document.body.innerHTML = '' + ['resource-first', 'resource-second'].map(resource => '
    ').join('') + '
    '; document.querySelectorAll('.map_activity_content_info_top').forEach((node, index) => { node.onclick = () => { qaSameClicks[index]++; history.pushState({}, '', '/clientcn/course/resource?resourceId=' + (index ? 'resource-second' : 'resource-first')); qaRenderCourse(index); document.querySelector('[data-course-title]').textContent = '同名课程'; }; }); }; qaRenderSameMap(); qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock, getSnapshot: () => ({ document, url: location.href, planId: 'same-title-fixture', accountId: 'fixture-account' }), navigate: async url => { qaSamePending = url; } }); await qaEngine.configure({ mode: 'video', execution: 'sequential' }); await qaEngine.start(); await qaEngine.tick(); }); assert.equal(await page.evaluate(() => qaSamePending), origin + 'learningmap/same-title-fixture'); for (let i = 0; i < 3; i++) await page.evaluate(() => qaEngine.tick()); const sameTitleStale = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, clicks: qaSameClicks })); const sameTitleSecond = sameTitleStale.state.tasks.find(task => task.kind === 'course' && task.identity === 'resource-second'); assert(sameTitleSecond, '保留相同名称但资源不同的第二课程'); assert(!['expanded', 'completed'].includes(sameTitleSecond.status), '同名旧课程不能冒充尚未点击入口的下一资源'); assert(!sameTitleStale.state.tasks.some(task => task.parentCourse === sameTitleSecond.id), '同名旧课件不能挂到第二资源'); assert.equal(new URL(sameTitleStale.url).searchParams.get('resourceId'), 'resource-first'); assert.deepEqual(sameTitleStale.clicks, [1, 0]); await page.evaluate(() => { history.pushState({}, '', qaSamePending); qaSamePending = ''; qaRenderSameMap(); }); for (let i = 0; i < 8; i++) { await page.evaluate(() => qaEngine.tick()); if (await page.evaluate(() => qaEngine.getState().video?.title === '视频二')) break; } const sameTitleNext = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, clicks: qaSameClicks })); assert.deepEqual(sameTitleNext.clicks, [1, 1]); assert.equal(new URL(sameTitleNext.url).searchParams.get('resourceId'), 'resource-second', '第二资源只能在原地图入口点击后进入'); assert.equal(sameTitleNext.state.video?.title, '视频二'); assert.equal(sameTitleNext.state.video?.status, 'playing'); checks++; // A worker reads its task checkpoint after restoring the shared session. // Its old checkpoint must not overwrite the fresh page's loading window. const workerRestore = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); const saved = await qaStorage.get('session.v1'); qaMemory.clear(); const workerURL = 'http://10.119.67.7/clientcn/course/worker-loading-fixture'; const nonce = 'checkpoint-window-fixture'; history.replaceState({}, '', workerURL + '#__yian_worker=' + nonce); document.body.innerHTML = '
    工作页课程
    开始学习
  • 工作页视频

    视频
  • '; window.qaWorkerCoverClicks = 0; document.querySelector('.course-courseware-playdefult > span').onclick = () => { qaWorkerCoverClicks++; }; const task = YianLearningEngine.discoverTasks(document, workerURL).find(item => item.kind === 'video'); Object.assign(task, { status: 'loading', openedAt: qaClock, retryCount: 2, lastRefreshAt: qaClock - 60000 }); Object.assign(saved, { status: 'running', tasks: [task], activeTaskId: task.id, accountId: 'fixture-account', planId: '', bankVersion: null, config: Object.assign({}, saved.config, { mode: 'video', execution: 'sequential' }) }); await qaStorage.set('session.v1', saved); await qaStorage.set('control.v1', { sessionId: saved.id, status: 'running', config: saved.config }); await qaStorage.set('worker:' + nonce, { nonce, sessionId: saved.id, taskId: task.id, url: workerURL, expires: qaClock + 30000, owner: 'old-worker' }); await qaStorage.set('task:' + saved.id + ':' + task.id, Object.assign({}, task, { openedAt: qaClock - 90000, coverClickedAt: qaClock - 90000 })); qaEngine = YianLearningEngine.create({ ownerId: 'checkpoint-worker', storage: qaStorage, now: () => qaClock, getSnapshot: () => ({ document, url: location.href, accountId: 'fixture-account' }) }); await qaEngine.tick(); return { state: qaEngine.getState(), clicks: qaWorkerCoverClicks, worker: await qaStorage.get('worker:' + nonce) }; }); assert.equal(workerRestore.worker.owner, 'checkpoint-worker', '本例确实恢复受控工作页'); assert.equal(workerRestore.state.status, 'running'); assert.equal(workerRestore.clicks, 1, 'checkpoint里的旧coverClickedAt不能阻止新工作页点击真实span'); assert.notEqual(workerRestore.state.tasks[0].status, 'waiting', '工作页checkpoint读取后仍有新加载窗口'); assert.equal(workerRestore.state.tasks[0].retryCount, 2, '页面恢复不清空自动恢复次数'); await page.evaluate(async () => { qaClock += 14999; await qaEngine.tick(); }); assert.notEqual(await page.evaluate(() => qaEngine.getState().tasks[0].status), 'waiting'); await page.evaluate(async () => { qaClock += 1002; await qaEngine.tick(); }); assert.equal(await page.evaluate(() => qaEngine.getState().tasks[0].status), 'waiting'); assert.equal(await page.evaluate(() => qaWorkerCoverClicks), 1, '工作页新加载窗口内只点一次封面'); checks++; // Full coordinator chain: video requirements include passing its related // exam, so an ended video is handed off and only the later green website // icon completes the course. Navigation remains asynchronous throughout. await page.evaluate(async () => { qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0)); qaMemory.clear(); history.replaceState({}, '', '/clientcn/learningmap/exam-handoff-fixture'); window.qaFull = { courseClicks: [0, 0], relatedClicks: 0, enterClicks: 0, coverClicks: 0, firstPlayCalls: 0, examStarts: 0, returns: 0, pending: '', navigations: [], examState: { stage: 'answering', total: 1, verifiedCount: 0 } }; window.qaFullURLs = { map: location.href, first: 'http://10.119.67.7/clientcn/course/handoff-first', second: 'http://10.119.67.7/clientcn/course/handoff-second', detail: 'http://10.119.67.7/clientcn/exam/handoff-exam', paper: 'http://10.119.67.7/clientcn/exam/handoff-exam/questions?flag=test' }; window.qaFullMap = () => { document.body.innerHTML = '' + ['联动课程', '后续课程'].map((title, index) => '
    ').join('') + '
    '; document.querySelectorAll('.map_activity_content_info_top').forEach((node, index) => { node.onclick = () => { qaFull.courseClicks[index]++; history.pushState({}, '', index ? qaFullURLs.second : qaFullURLs.first); qaFullCourse(index, false); }; }); }; window.qaFullCourse = (index, returning) => { const title = index ? '后续课程' : '联动课程'; document.body.innerHTML = '
    ' + title + '
    ' + (returning ? '
    开始学习
    ' : '') + '
  • ' + (index ? '后续视频' : '联动视频') + '

    视频
    尚未完成
    ' + (index ? '' : '') + '
  • '; const icon = document.querySelector('.en_courseware_noFinished'), tip = document.querySelector('.completion-tooltip'); icon.onmouseover = () => { tip.textContent = index ? '1.观看视频进度100%' : '1.观看视频进度100% 2.完成考试答题,并且及格通过'; tip.hidden = false; }; icon.onmouseout = () => { tip.hidden = true; }; const cover = document.querySelector('.course-courseware-playdefult > span'); if (cover) cover.onclick = () => { qaFull.coverClicks++; }; const video = document.querySelector('video'); if (video) { const media = { currentTime: index ? 20 : 100, duration: 100, ended: !index, paused: true, playbackRate: 1 }; for (const key of Object.keys(media)) Object.defineProperty(video, key, { configurable: true, get: () => media[key], set: value => { media[key] = value; } }); video.play = async () => { if (!index) qaFull.firstPlayCalls++; media.paused = false; }; video.pause = () => { media.paused = true; }; } const related = document.querySelector('.related_test'); if (related) related.onclick = event => { event.stopPropagation(); qaFull.relatedClicks++; history.pushState({}, '', qaFullURLs.detail); document.body.innerHTML = '
    联动课程 已答0/1

    总考试次数:3 次

    开始答题
    '; document.querySelector('.examdetail_button').onclick = () => { qaFull.enterClicks++; history.pushState({}, '', qaFullURLs.paper); document.body.innerHTML = '
    联动课程
    '; }; }; }; window.qaFullFlush = () => { if (!qaFull.pending) return false; const url = qaFull.pending; qaFull.pending = ''; history.pushState({}, '', url); if (url === qaFullURLs.map) qaFullMap(); else if (url === qaFullURLs.first) { qaFull.returns++; qaFullCourse(0, true); } else throw new Error('Unexpected fixture destination: ' + url); return true; }; qaFullMap(); qaEngine = YianLearningEngine.create({ storage: qaStorage, now: () => qaClock, getSnapshot: () => ({ document, url: location.href, planId: 'exam-handoff-fixture', accountId: 'fixture-account', courses: ['联动课程'] }), getExamState: () => qaFull.examState, onExamStart: async () => { qaFull.examStarts++; return true; }, navigate: async url => { qaFull.navigations.push(url); qaFull.pending = url; } }); await qaEngine.configure({ mode: 'all', execution: 'sequential' }); await qaEngine.start(); }); for (let i = 0; i < 8; i++) { await page.evaluate(() => qaEngine.tick()); if (await page.evaluate(() => qaEngine.getState().tasks.some(task => task.title === '联动视频' && task.status === 'waitingExam'))) break; } const handoff = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull })); assert.equal(handoff.state.tasks.find(task => task.title === '联动视频').status, 'waitingExam', '结束视频按网页提示转考试,不能提前计完成'); assert.equal(handoff.state.video?.platformComplete, false); assert.equal(handoff.flow.relatedClicks, 0, '交接阶段保留视频与考试独立状态'); for (let i = 0; i < 8; i++) { await page.evaluate(() => qaEngine.tick()); if (await page.evaluate(() => qaFull.examStarts === 1)) break; } const paper = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull, url: location.href })); assert.equal(paper.flow.relatedClicks, 1, '仅点击一次真实关联考试入口'); assert.equal(paper.flow.enterClicks, 1, '仅通过正常详情开考按钮进入试卷'); assert.equal(paper.flow.examStarts, 1); assert.equal(paper.url, origin + 'exam/handoff-exam/questions?flag=test'); assert.equal(paper.state.tasks.filter(task => task.kind === 'exam').length, 1, '目录、详情和试卷为同一场考试'); await page.evaluate(() => { qaFull.examState = { stage: 'completed', total: 1, verifiedCount: 1, submitted: true, confirmed: true }; }); for (let i = 0; i < 8; i++) { await page.evaluate(() => qaEngine.tick()); if (await page.evaluate(() => qaFull.pending === qaFullURLs.first)) break; } assert.equal(await page.evaluate(() => qaFull.pending), origin + 'course/handoff-first', '考试完成后返回实际原课程核对网站完成标记'); for (let i = 0; i < 2; i++) await page.evaluate(() => qaEngine.tick()); const stillPaper = await page.evaluate(() => ({ state: qaEngine.getState(), url: location.href, flow: qaFull })); assert.equal(stillPaper.url, origin + 'exam/handoff-exam/questions?flag=test'); assert.notEqual(stillPaper.state.tasks.find(task => task.title === '联动视频').status, 'waitingPlatform', '异步返回尚未生效时不能在考试页假装等待课程完成'); assert.equal(stillPaper.flow.coverClicks, 0); assert.equal(await page.evaluate(() => qaFullFlush()), true); await page.evaluate(() => qaEngine.tick()); const returned = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull })); assert.equal(returned.state.tasks.find(task => task.title === '联动视频').status, 'waitingPlatform'); assert.equal(returned.flow.returns, 1); assert.equal(returned.flow.coverClicks, 0, '原课程封面出现时不重播已经到末尾的视频'); await page.evaluate(() => { const icon = document.querySelector('.en_courseware_noFinished'); icon.classList.remove('en_courseware_noFinished'); icon.textContent = '已完成'; }); for (let i = 0; i < 8; i++) { await page.evaluate(() => qaEngine.tick()); if (await page.evaluate(() => qaFull.pending === qaFullURLs.map)) break; } assert.equal(await page.evaluate(() => qaFull.pending), origin + 'learningmap/exam-handoff-fixture'); assert.equal(await page.evaluate(() => qaEngine.getState().tasks.find(task => task.title === '联动视频').status), 'completed', '仅真实绿色完成图标才完成原视频任务'); assert.equal(await page.evaluate(() => qaFullFlush()), true); for (let i = 0; i < 8; i++) { await page.evaluate(() => qaEngine.tick()); if (await page.evaluate(() => qaEngine.getState().video?.title === '后续视频')) break; } const full = await page.evaluate(() => ({ state: qaEngine.getState(), flow: qaFull, url: location.href })); assert.deepEqual(full.flow.courseClicks, [1, 1]); assert.equal(full.flow.coverClicks, 0); assert.equal(full.flow.relatedClicks, 1); assert.equal(full.flow.enterClicks, 1); assert.equal(full.flow.examStarts, 1); assert.equal(full.state.tasks.filter(task => task.kind === 'exam').length, 1); assert.equal(full.state.tasks.find(task => task.kind === 'exam').status, 'completed'); assert.equal(full.state.tasks.find(task => task.title === '联动视频').status, 'completed'); assert.equal(full.url, origin + 'course/handoff-second'); assert.equal(full.state.video?.title, '后续视频'); assert.equal(full.state.video?.status, 'playing'); checks++; assert.deepEqual(blocked, []); assert.deepEqual(errors, []); console.log('course-map-navigation: ' + checks + ' checks passed (map return, resource identity, fresh retry and worker checkpoint windows, video/exam/green-icon/next-course chain; no business requests)'); } finally { await page.evaluate(() => window.qaEngine?.destroy()).catch(() => {}); await context.close(); await browser.close(); } } main().catch(error => { console.error(error); process.exitCode = 1; });