/* 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 = '总考试次数: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; });