fix: recover expired task leases and persist diagnostic logs
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/* Complete userscript diagnostics workflow; every business request is intercepted. */
|
||||
'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 bundle = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
|
||||
const qaDir = path.join(os.tmpdir(), 'yianxue-diagnostics-qa');
|
||||
const course = '诊断回归测试课程';
|
||||
const targetURL = 'http://10.119.67.7/clientcn/course/diagnostics?token=SECRET';
|
||||
const settingPrefix = 'yianxue.assistant.setting.v2.';
|
||||
const learningPrefix = settingPrefix + 'learning:';
|
||||
const bankKey = 'yianxue.assistant.bank.v3';
|
||||
const html = '<!doctype html><meta charset="utf-8"><title>诊断面板本地回归</title><style>body{font:15px system-ui;background:#eef2f7;margin:24px}video{display:block;width:min(640px,100%);height:220px;background:#15243c}li{list-style:none;padding:12px;background:white}</style><body><h1>诊断面板本地模拟</h1><div data-user-id="diagnostics-account"></div><app-course><div data-course-title>' + course + '</div><li class="cn_courseware_img active" data-courseware-id="diagnostics-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></li><app-course-courseware-video><video id="diagnostics-player" controls></video></app-course-courseware-video></app-course></body>';
|
||||
const sandbox = '(function(){const GM_getValue=(key,fallback)=>window.__diagnosticsRead(key,fallback);const GM_setValue=(key,value)=>window.__diagnosticsWrite(key,value);\n' + bundle + '\n})();';
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(qaDir, { recursive: true });
|
||||
const memory = new Map();
|
||||
const errors = [], navigationRequests = [], unexpectedRequests = [];
|
||||
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 1366, height: 900 }, acceptDownloads: true });
|
||||
await context.exposeBinding('__diagnosticsRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
|
||||
await context.exposeBinding('__diagnosticsWrite', (_, key, value) => memory.set(key, structuredClone(value)));
|
||||
await context.route('**/*', route => {
|
||||
if (route.request().isNavigationRequest() && route.request().url() === targetURL) {
|
||||
navigationRequests.push(route.request().url());
|
||||
return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: html });
|
||||
}
|
||||
unexpectedRequests.push(route.request().url()); return route.abort();
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
async function install(offset = 0) {
|
||||
await page.evaluate(offset => {
|
||||
window.qaOffset = offset;
|
||||
Date.now = () => Date.UTC(2026, 9, 18, 10) + qaOffset;
|
||||
window.qaMedia = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1, plays: 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.plays++; qaMedia.paused = false; };
|
||||
video.pause = () => { qaMedia.paused = true; };
|
||||
}, offset);
|
||||
await page.addScriptTag({ content: sandbox });
|
||||
await page.evaluate(() => YianAssistant.ready);
|
||||
assert.equal(await page.evaluate(() => typeof YianAssistant.getDiagnosticState), 'function', '完整bundle必须提供真实诊断API');
|
||||
}
|
||||
async function consoleTab() {
|
||||
const expand = page.getByRole('button', { name: '展开题库助手', exact: true });
|
||||
if (await expand.isVisible()) await expand.click();
|
||||
await page.getByTestId('tab-console').click();
|
||||
await page.getByTestId('diagnostic-status').waitFor({ state: 'visible' });
|
||||
}
|
||||
async function download(format, invoke) {
|
||||
const waiting = page.waitForEvent('download');
|
||||
await invoke();
|
||||
const result = await waiting;
|
||||
assert.match(result.suggestedFilename(), new RegExp('\\.' + format + '$'));
|
||||
assert.equal(await result.failure(), null);
|
||||
const destination = path.join(qaDir, 'diagnostics-' + format + '.' + format);
|
||||
await result.saveAs(destination);
|
||||
const text = fs.readFileSync(destination, 'utf8');
|
||||
for (const forbidden of ['SECRET', '不可写入诊断的题干', '不可写入诊断的正确答案']) assert(!text.includes(forbidden), forbidden + '不得出现在导出诊断中');
|
||||
return text;
|
||||
}
|
||||
const protectedStorage = () => [...memory].filter(([key]) => key === bankKey || key.startsWith(learningPrefix)).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
await page.goto(targetURL); await install();
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().enabled), true);
|
||||
const bank = [{ course, type: '单选', question: '不可写入诊断的题干', options: [{ label: 'A', text: '不可写入诊断的正确答案' }, { label: 'B', text: '错误项' }], answer: 'A' }];
|
||||
await page.evaluate(async bank => {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(bank));
|
||||
await YianAssistant.importFiles([{ name: '诊断回归题库.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
|
||||
await YianAssistant.taskAction('start');
|
||||
}, bank);
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused);
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), 1);
|
||||
const control = memory.get(learningPrefix + 'control.v1');
|
||||
const task = await page.evaluate(() => YianAssistant.getLearningState().tasks.find(task => task.kind === 'video'));
|
||||
const held = memory.get(learningPrefix + 'lease:video');
|
||||
assert(held && held.owner, '正常任务首先领取真实执行权');
|
||||
memory.set(learningPrefix + 'lease:video', { ...held, owner: 'fixture-foreign-owner', sessionId: control.sessionId, taskId: task.id, expires: Date.UTC(2026, 9, 18, 10) + 18000, pageUrl: targetURL });
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().tasks.some(task => task.status === 'waitingLease'));
|
||||
const playsDuringWait = await page.evaluate(() => qaMedia.plays);
|
||||
await page.waitForTimeout(650);
|
||||
assert.equal(await page.evaluate(() => qaMedia.plays), playsDuringWait, '有效外部执行权下不能重复发起播放');
|
||||
await page.evaluate(() => { qaOffset += 19000; qaMedia.currentTime += 1; });
|
||||
await page.waitForFunction(() => YianAssistant.getLearningState().tasks.some(task => task.kind === 'video' && task.status === 'playing'));
|
||||
await page.evaluate(() => YianAssistant.flushDiagnostics());
|
||||
await consoleTab();
|
||||
assert.equal(await page.getByTestId('diagnostic-record').isChecked(), true);
|
||||
const jsonText = await download('json', () => page.getByTestId('diagnostic-exportJson').click());
|
||||
const first = JSON.parse(jsonText);
|
||||
assert.equal(first.schema, 1); assert(Array.isArray(first.records) && first.records.length > 0);
|
||||
const events = new Set(first.records.map(record => record.event));
|
||||
for (const event of ['lease.wait', 'lease.expired', 'lease.acquired', 'lease.resumed', 'task.action']) assert(events.has(event), '完整工作流必须记录 ' + event);
|
||||
const waitEntry = first.records.find(record => record.event === 'lease.wait');
|
||||
assert.equal(waitEntry.details.owner, 'fixture-foreign-owner');
|
||||
assert.equal(waitEntry.context.url, targetURL.split('?')[0]);
|
||||
const textExport = await download('txt', () => page.getByTestId('diagnostic-exportText').click());
|
||||
assert(textExport.includes('lease.wait') && textExport.includes('lease.resumed'));
|
||||
await page.screenshot({ path: path.join(qaDir, '01-desktop-diagnostics.png'), fullPage: false });
|
||||
console.log('PASS 完整bundle真实执行权等待→19秒后恢复→JSON/TXT下载,记录动作及执行权事件并移除URL查询和题目正文');
|
||||
|
||||
// A manual reload starts paused, reads the persisted records, and keeps all
|
||||
// previous event identities available for diagnosis.
|
||||
const previousIds = first.records.map(record => record.id);
|
||||
await page.reload(); await install(19000); await consoleTab();
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().status), 'paused');
|
||||
const reloaded = JSON.parse(await download('json', () => page.evaluate(() => YianAssistant.exportDiagnostics('json'))));
|
||||
const reloadedIds = new Set(reloaded.records.map(record => record.id));
|
||||
for (const id of previousIds) assert(reloadedIds.has(id), '刷新后保留原诊断记录 ' + id);
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), 1);
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().tasks.length), 1);
|
||||
console.log('PASS 手动刷新后任务暂停,持久诊断与题库、任务检查点均保留');
|
||||
|
||||
// Exercise the rendered recording switch, rather than monkeypatching a
|
||||
// logger. Actual pause/resume operations then produce no new records.
|
||||
await page.getByTestId('diagnostic-record').uncheck();
|
||||
await page.waitForFunction(() => YianAssistant.getDiagnosticState().enabled === false);
|
||||
await page.evaluate(() => YianAssistant.flushDiagnostics());
|
||||
const countWhenOff = await page.evaluate(() => YianAssistant.getDiagnosticState().total);
|
||||
await page.evaluate(async () => { await YianAssistant.taskAction('resume'); await YianAssistant.taskAction('pause'); });
|
||||
await page.waitForTimeout(700); await page.evaluate(() => YianAssistant.flushDiagnostics());
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().total), countWhenOff);
|
||||
assert.equal(await page.getByTestId('diagnostic-record').isChecked(), false);
|
||||
assert((await page.getByTestId('diagnostic-status').textContent()).includes('已停止新增记录'));
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.getByTestId('diagnostic-exportText').scrollIntoViewIfNeeded();
|
||||
const panelBox = await page.locator('#yian-question-assistant').boundingBox();
|
||||
assert(panelBox && panelBox.x >= -1 && panelBox.x + panelBox.width <= 391, '390px宽下诊断面板保持在视口内');
|
||||
await page.screenshot({ path: path.join(qaDir, '02-mobile-diagnostics.png'), fullPage: false });
|
||||
console.log('PASS 诊断记录开关关闭后真实任务状态变更不再新增日志,390px操作可用');
|
||||
|
||||
const checkpointBefore = structuredClone(protectedStorage());
|
||||
const stateBefore = await page.evaluate(() => ({ tasks: YianAssistant.getLearningState().tasks, total: YianAssistant.getSnapshot().total }));
|
||||
assert(checkpointBefore.some(([key]) => key.endsWith('session.v1')), '测试必须包含已保存任务检查点');
|
||||
await page.getByTestId('diagnostic-clearSaved').click();
|
||||
await page.waitForFunction(() => YianAssistant.getDiagnosticState().total === 0);
|
||||
await page.evaluate(() => YianAssistant.flushDiagnostics());
|
||||
assert.deepEqual(protectedStorage(), checkpointBefore, '清空诊断不能改写题库、学习任务或检查点');
|
||||
assert.deepEqual(await page.evaluate(() => ({ tasks: YianAssistant.getLearningState().tasks, total: YianAssistant.getSnapshot().total })), stateBefore);
|
||||
const cleared = JSON.parse(await download('json', () => page.evaluate(() => YianAssistant.exportDiagnostics('json'))));
|
||||
assert.deepEqual(cleared.records, []);
|
||||
await page.reload(); await install(19000); await consoleTab();
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().enabled), false, '关闭记录设置跨刷新保留');
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getDiagnosticState().total), 0, '清空记录不能被刷新旧缓存复活');
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), 1);
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().tasks.length), 1);
|
||||
console.log('PASS 清空已保存诊断只删除诊断,保留任务、题库、检查点及关闭设置');
|
||||
|
||||
assert.deepEqual(errors, []); assert.deepEqual(unexpectedRequests, []);
|
||||
assert.equal(navigationRequests.length, 3, '初次打开与两次刷新全由route本地提供,零真实业务请求');
|
||||
await context.close();
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user