296 lines
17 KiB
JavaScript
296 lines
17 KiB
JavaScript
/* Isolated scheduler lease regression: all business routes are local fixtures. */
|
|
'use strict';
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { chromium } = require('playwright');
|
|
|
|
const workspace = path.resolve(__dirname, '..');
|
|
const sources = ['question-bank-core.js', 'attempt-policy.js', 'learning-engine.js']
|
|
.map(file => fs.readFileSync(path.join(workspace, file), 'utf8'));
|
|
const origin = 'http://10.119.67.7/clientcn/';
|
|
const course = '租约恢复测试课程';
|
|
const videoHTML = '<app-course><div data-course-title>' + course + '</div><li class="cn_courseware_img active" data-courseware-id="lease-video" data-required-seconds="100"><p>测试视频</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习20秒</div><div class="courseware_finished_status"><span class="en_courseware_finished en_courseware_noFinished">尚未完成</span></div><p class="related_test">' + course + '</p></li><app-course-courseware-video><video id="lease-player"></video></app-course-courseware-video></app-course>';
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
|
let checks = 0;
|
|
const allErrors = [], allUnexpected = [];
|
|
try {
|
|
async function fixture(options = {}) {
|
|
const memory = new Map();
|
|
const context = await browser.newContext();
|
|
await context.exposeBinding('__leaseRead', (_, key) => memory.has(key) ? structuredClone(memory.get(key)) : null);
|
|
await context.exposeBinding('__leaseWrite', (_, key, value) => memory.set(key, structuredClone(value)));
|
|
await context.route('**/*', route => {
|
|
if (route.request().isNavigationRequest() && route.request().url().startsWith(origin)) {
|
|
return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: '<!doctype html><meta charset="utf-8"><body></body>' });
|
|
}
|
|
allUnexpected.push(route.request().url()); return route.abort();
|
|
});
|
|
const page = await context.newPage();
|
|
page.on('pageerror', error => allErrors.push(error.message));
|
|
await page.goto(origin + 'course/lease-recovery');
|
|
for (const content of sources) await page.addScriptTag({ content });
|
|
await page.evaluate(async ({ videoHTML, course, options }) => {
|
|
document.body.innerHTML = videoHTML;
|
|
window.qaClock = 100000;
|
|
window.qaRelated = 0; window.qaEnter = 0; window.qaExamStarts = 0; window.qaExamPauses = 0; window.qaExamActive = false; window.qaPlay = 0;
|
|
window.qaExamState = { stage: 'answering', total: 40, verifiedCount: 0 };
|
|
window.qaVideo = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 };
|
|
const video = document.querySelector('video');
|
|
for (const key of Object.keys(qaVideo)) Object.defineProperty(video, key, { configurable: true, get: () => qaVideo[key], set: value => { qaVideo[key] = value; } });
|
|
video.play = async () => { qaPlay++; qaVideo.paused = false; };
|
|
video.pause = () => { qaVideo.paused = true; };
|
|
window.qaSnapshot = { accountId: 'lease-account', bankVersion: 1, planId: 'lease-plan', courses: [course] };
|
|
window.qaStorage = { get: key => __leaseRead(key), set: (key, value) => __leaseWrite(key, value) };
|
|
document.querySelector('.related_test').onclick = () => {
|
|
qaRelated++;
|
|
history.pushState({}, '', '/clientcn/exam/lease-recovery');
|
|
document.body.innerHTML = '<app-examdetail><div class="examdetail_header">' + course + ' 已答0/40</div>' + (options.singleAttempt ? '<p>剩余考试次数:1次</p>' : '') + '<div class="examdetail_button">开始答题</div></app-examdetail>';
|
|
document.querySelector('.examdetail_button').onclick = () => {
|
|
qaEnter++;
|
|
history.pushState({}, '', '/clientcn/exam/lease-recovery/questions');
|
|
document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">' + course + '</div></app-examtest>';
|
|
};
|
|
};
|
|
window.qaCreate = () => YianLearningEngine.create({
|
|
ownerId: 'local-owner', now: () => qaClock, storage: qaStorage,
|
|
getSnapshot: () => Object.assign({ document, url: location.href }, qaSnapshot),
|
|
getExamState: () => qaExamState,
|
|
onExamStart: async () => { qaExamStarts++; qaExamActive = true; return true; },
|
|
onExamPause: () => { qaExamPauses++; qaExamActive = false; }, navigate: async () => { throw new Error('fixture only uses the actual associated exam element'); }
|
|
});
|
|
window.qaEngine = qaCreate();
|
|
await qaEngine.configure({ mode: 'all', execution: 'sequential' });
|
|
await qaEngine.start();
|
|
}, { videoHTML, course, options });
|
|
const tick = delta => page.evaluate(async delta => { qaClock += delta || 0; return qaEngine.tick(); }, delta);
|
|
const read = () => page.evaluate(() => ({ state: qaEngine.getState(), related: qaRelated, entered: qaEnter, starts: qaExamStarts, examPauses: qaExamPauses, examActive: qaExamActive, plays: qaPlay, videoPaused: qaVideo.paused }));
|
|
async function blockExam() {
|
|
const state = await page.evaluate(async () => {
|
|
const control = await qaStorage.get('control.v1');
|
|
const task = qaEngine.getState().tasks.find(item => item.kind === 'exam');
|
|
await qaStorage.set('lease:exam', { owner: 'other-page-owner', sessionId: control.sessionId, taskId: task.id, expires: qaClock + 18000 });
|
|
document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true');
|
|
await qaEngine.tick();
|
|
return qaEngine.tick();
|
|
});
|
|
const current = await read();
|
|
assert.equal(state.status, 'running'); assert.equal(current.related, 0); assert.equal(current.entered, 0); assert.equal(current.starts, 0);
|
|
assert.match(state.message + ' ' + state.tasks.find(item => item.kind === 'exam').reason, /租约|执行权|另一(?:个)?页面/);
|
|
}
|
|
async function runUntilExam() {
|
|
for (let i = 0; i < 5; i++) await tick(0);
|
|
const current = await read();
|
|
assert.equal(current.related, 1, '租约失效后自动点击原关联考试入口');
|
|
assert.equal(current.entered, 1, '不需要用户再次点击继续即可进入本场试卷');
|
|
assert.equal(current.starts, 1, '仅启动一次当前考试控制器');
|
|
assert.doesNotMatch(current.state.message, /等待同类任务租约释放|等待(?:视频|考试)执行权/, '领取成功后不得残留等待执行权提示');
|
|
return current;
|
|
}
|
|
return { page, context, memory, tick, read, blockExam, runUntilExam, close: () => context.close() };
|
|
}
|
|
|
|
// Upgrade the exact persisted 2.1.2 wait reason. A controlled reload keeps
|
|
// this running session while the old waiting task becomes retryable.
|
|
{
|
|
const f = await fixture();
|
|
try {
|
|
await f.blockExam();
|
|
const restored = await f.page.evaluate(async () => {
|
|
qaEngine.destroy(); await new Promise(resolve => setTimeout(resolve, 0));
|
|
const saved = await qaStorage.get('session.v1');
|
|
const exam = saved.tasks.find(task => task.kind === 'exam');
|
|
exam.status = 'waiting'; exam.reason = '另一个页面正在处理同类任务,避免重复领取';
|
|
delete exam.leaseWait;
|
|
saved.activeTaskId = ''; saved.status = 'running';
|
|
await qaStorage.set('session.v1', saved);
|
|
await qaStorage.set('recover:' + location.href, { sessionId: saved.id, taskId: '', accountId: 'lease-account', expires: qaClock + 60000 });
|
|
qaEngine = qaCreate(); return qaEngine.tick();
|
|
});
|
|
assert.equal(restored.status, 'running'); assert.equal((await f.read()).related, 0);
|
|
await f.tick(18001); await f.runUntilExam(); checks++;
|
|
} finally { await f.close(); }
|
|
}
|
|
|
|
// The completed video leaves a pending exam whose previous executor closed.
|
|
// An unexpired lease waits, then the very same running session retries itself.
|
|
{
|
|
const f = await fixture();
|
|
try {
|
|
await f.blockExam(); await f.tick(17999);
|
|
assert.equal((await f.read()).related, 0, '有效期内不能重复打开考试');
|
|
await f.tick(2); await f.runUntilExam(); checks++;
|
|
} finally { await f.close(); }
|
|
}
|
|
|
|
// A real second browser page renews its lease. The coordinator must leave
|
|
// that page alone for arbitrarily many lease lifetimes, then recover once it
|
|
// stops heartbeating. Its writes use the same shared storage binding.
|
|
{
|
|
const f = await fixture();
|
|
try {
|
|
await f.blockExam();
|
|
const other = await f.context.newPage();
|
|
other.on('pageerror', error => allErrors.push(error.message));
|
|
await other.goto(origin + 'exam/other-lease-holder');
|
|
for (let i = 0; i < 4; i++) {
|
|
await other.evaluate(async clock => {
|
|
const held = await __leaseRead('lease:exam');
|
|
if (!held || held.owner !== 'other-page-owner') throw new Error('另一个真实页面的租约被抢占');
|
|
held.expires = clock + 18000; await __leaseWrite('lease:exam', held);
|
|
}, 100000 + (i + 1) * 15000);
|
|
await f.tick(15000);
|
|
const current = await f.read();
|
|
assert.equal(current.related, 0); assert.equal(current.entered, 0); assert.equal(current.starts, 0);
|
|
assert.equal(f.memory.get('lease:exam').owner, 'other-page-owner');
|
|
}
|
|
await other.close(); await f.tick(18001); await f.runUntilExam(); checks++;
|
|
} finally { await f.close(); }
|
|
}
|
|
|
|
// An active task can lose a lease without entering the async-quiz path.
|
|
// Restoring its own lease lets it continue and must clear the old banner.
|
|
{
|
|
const f = await fixture();
|
|
try {
|
|
await f.page.evaluate(async () => {
|
|
const held = await qaStorage.get('lease:video');
|
|
held.owner = 'other-page-owner'; held.expires = qaClock + 18000; await qaStorage.set('lease:video', held);
|
|
});
|
|
const blocked = await f.tick(1000);
|
|
assert.match(blocked.message, /租约|执行权/);
|
|
const before = await f.read();
|
|
assert.equal(before.videoPaused, true, '丢失执行权后立即暂停本页旧视频');
|
|
await f.page.evaluate(async () => {
|
|
const held = await qaStorage.get('lease:video'); held.owner = 'local-owner'; held.expires = qaClock - 1;
|
|
await qaStorage.set('lease:video', held);
|
|
qaVideo.currentTime += 1;
|
|
});
|
|
const recovered = await f.tick(1000);
|
|
assert.equal(recovered.status, 'running');
|
|
assert.equal(f.memory.get('lease:video').owner, 'local-owner');
|
|
assert.ok(f.memory.get('lease:video').expires > 102000);
|
|
assert.doesNotMatch(recovered.message, /等待同类任务租约释放|等待(?:视频|考试)执行权/);
|
|
const after = await f.read();
|
|
assert.equal(after.starts, before.starts);
|
|
assert.equal(after.videoPaused, false, '重新取得执行权后正常恢复原视频');
|
|
assert.equal(after.plays, before.plays + 1, '恢复时重启本页播放器一次'); checks++;
|
|
} finally { await f.close(); }
|
|
}
|
|
|
|
// Losing an already-started exam lease invalidates its old answer/submit
|
|
// controller immediately. Waiting is automatic, while reacquisition must
|
|
// call the normal exam entry callback again to reverify the current paper.
|
|
{
|
|
const f = await fixture();
|
|
try {
|
|
await f.blockExam(); await f.tick(18001); await f.runUntilExam();
|
|
const initial = await f.read();
|
|
assert.equal(initial.examActive, true);
|
|
await f.page.evaluate(async () => {
|
|
const held = await qaStorage.get('lease:exam');
|
|
held.owner = 'other-exam-page'; held.expires = qaClock + 18000;
|
|
await qaStorage.set('lease:exam', held);
|
|
});
|
|
const blocked = await f.tick(1);
|
|
let current = await f.read();
|
|
assert.equal(blocked.status, 'running', '执行权等待不需要重新人工启动全部任务');
|
|
assert.equal(current.state.tasks.find(task => task.kind === 'exam').status, 'waitingLease');
|
|
assert.equal(current.examPauses, initial.examPauses + 1, '首次丢失考试执行权立即停止旧答题控制器');
|
|
assert.equal(current.examActive, false); assert.equal(current.starts, 1);
|
|
assert.equal(f.memory.get('lease:exam').owner, 'other-exam-page', '不得抢占有效的其他页面考试执行权');
|
|
await f.tick(17998); current = await f.read();
|
|
assert.equal(current.examPauses, initial.examPauses + 1, '等待轮询不重复暂停或开启旧控制器');
|
|
assert.equal(current.examActive, false); assert.equal(current.starts, 1);
|
|
assert.equal(f.memory.get('lease:exam').owner, 'other-exam-page');
|
|
await f.tick(2); current = await f.read();
|
|
assert.equal(current.starts, 2, '过期重新领取后必须重新核验当前试卷');
|
|
assert.equal(current.examActive, true);
|
|
assert.equal(f.memory.get('lease:exam').owner, 'local-owner');
|
|
assert.equal(current.entered, 1, '重验已有试卷不得再次点击开始答题');
|
|
await f.tick(0); assert.equal((await f.read()).starts, 2, '核验通过后不重复重启考试控制器');
|
|
checks++;
|
|
} finally { await f.close(); }
|
|
}
|
|
|
|
// Reacquiring a manual-confirmation exam must keep its read-only waiting
|
|
// state instead of silently restarting the automatic answer controller.
|
|
{
|
|
const f = await fixture();
|
|
try {
|
|
await f.blockExam(); await f.tick(18001); await f.runUntilExam();
|
|
await f.page.evaluate(() => {
|
|
qaExamState = { stage: 'waiting', total: 40, verifiedCount: 32, predictedScore: 80, requiresManualSubmission: true, message: '本场等待用户手动确认交卷' };
|
|
qaExamActive = false;
|
|
});
|
|
await f.tick(0);
|
|
const initial = await f.read();
|
|
assert.equal(initial.state.tasks.find(task => task.kind === 'exam').status, 'waiting');
|
|
await f.page.evaluate(async () => {
|
|
const held = await qaStorage.get('lease:exam'); held.owner = 'other-exam-page'; held.expires = qaClock + 18000;
|
|
await qaStorage.set('lease:exam', held);
|
|
});
|
|
await f.tick(1); await f.tick(18000); await f.tick(0);
|
|
const current = await f.read();
|
|
assert.equal(current.state.tasks.find(task => task.kind === 'exam').status, 'waiting', '人工确认等待不能因执行权恢复而转回自动作答');
|
|
assert.equal(current.starts, 1); assert.equal(current.examActive, false);
|
|
assert.equal(current.examPauses, initial.examPauses, '已处于人工等待时保留原考试控制器的只读结果同步');
|
|
checks++;
|
|
} finally { await f.close(); }
|
|
}
|
|
|
|
// Lease expiry is never an authorization to ignore user pause or identity.
|
|
for (const protection of ['globalPause', 'examPause', 'bankVersion', 'account']) {
|
|
const f = await fixture();
|
|
try {
|
|
await f.blockExam();
|
|
await f.page.evaluate(async protection => {
|
|
if (protection === 'globalPause') await qaEngine.pause();
|
|
else if (protection === 'examPause') await qaEngine.configure({ examPaused: true });
|
|
else if (protection === 'bankVersion') qaSnapshot.bankVersion = 2;
|
|
else qaSnapshot.accountId = 'different-account';
|
|
}, protection);
|
|
await f.tick(18001); for (let i = 0; i < 4; i++) await f.tick(0);
|
|
const current = await f.read();
|
|
assert.equal(current.entered, 0, protection + '不得自动开始考试');
|
|
assert.equal(current.starts, 0, protection + '不得启动答题控制器');
|
|
if (protection !== 'examPause') assert.equal(current.state.status, 'paused');
|
|
checks++;
|
|
} finally { await f.close(); }
|
|
}
|
|
|
|
// Topic preflight is rerun after waiting, so a no-longer-associated bank
|
|
// cannot inherit a previously matched topic merely because the lease freed.
|
|
{
|
|
const f = await fixture();
|
|
try {
|
|
await f.blockExam();
|
|
await f.page.evaluate(() => { qaSnapshot.courses = ['其他课程']; });
|
|
await f.tick(18001); for (let i = 0; i < 4; i++) await f.tick(0);
|
|
const current = await f.read();
|
|
assert.equal(current.related, 0); assert.equal(current.entered, 0); assert.equal(current.starts, 0);
|
|
const exam = current.state.tasks.find(task => task.kind === 'exam');
|
|
assert.match(exam.reason, /题库|主题/); checks++;
|
|
} finally { await f.close(); }
|
|
}
|
|
|
|
// The normal one-attempt rule still runs on the detail page after a lease
|
|
// retry opens its associated exam, with no early entry or controller start.
|
|
{
|
|
const f = await fixture({ singleAttempt: true });
|
|
try {
|
|
await f.blockExam(); await f.tick(18001);
|
|
for (let i = 0; i < 5; i++) await f.tick(0);
|
|
const current = await f.read();
|
|
assert.equal(current.related, 1); assert.equal(current.entered, 0); assert.equal(current.starts, 0);
|
|
assert.equal(current.state.exam.requiresManualSubmission, true);
|
|
assert.match(current.state.tasks.find(task => task.kind === 'exam').reason, /1 次|1次/); checks++;
|
|
} finally { await f.close(); }
|
|
}
|
|
assert.deepEqual(allErrors, []); assert.deepEqual(allUnexpected, []);
|
|
console.log('lease-recovery:', checks, 'checks passed (自动接续、真实第二页续租、旧提示清理、暂停/身份/主题/单次机会保护;零真实业务请求)');
|
|
} finally { await browser.close(); }
|
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|