fix: recover expired task leases and persist diagnostic logs
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/* Local diagnostics: storage reload, bounded payloads, privacy and failure recovery. */
|
||||
const assert = require('node:assert/strict');
|
||||
const loggerModule = require('../diagnostic-logger.js');
|
||||
const copy = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||
function memory(initial) {
|
||||
const values = new Map(Object.entries(initial || {}));
|
||||
return { name: 'userscript', values, writes: 0, async get(key, fallback) { return values.has(key) ? copy(values.get(key)) : fallback; }, async set(key, value) { this.writes++; values.set(key, copy(value)); } };
|
||||
}
|
||||
async function main() {
|
||||
let time = Date.UTC(2026, 9, 18, 10);
|
||||
const storage = memory(); const states = [];
|
||||
const logger = loggerModule.create({ storage, now: () => time, onState: state => states.push(state), getContext: () => ({ url: 'http://10.119.67.7/clientcn/course?id=private#secret', pageKind: 'course', taskId: 'task-1', status: 'running', password: 'private' }) });
|
||||
await logger.ready;
|
||||
assert.equal(logger.getState().enabled, true);
|
||||
assert.equal(logger.getState().total, 0);
|
||||
logger.record('info', 'lease.wait', '等待同类执行权', { taskId: 'task-1', owner: 'other-window', expiresAt: time + 18000 });
|
||||
time += 900;
|
||||
logger.record('info', 'lease.wait', '等待同类执行权', { taskId: 'task-1', owner: 'other-window', expiresAt: time + 17100 });
|
||||
assert.equal(logger.getRecords().length, 1);
|
||||
assert.equal(logger.getRecords()[0].count, 2);
|
||||
assert.equal(logger.getRecords()[0].lastTime, time);
|
||||
assert.equal(storage.writes, 0, '批次时间前不逐条写入');
|
||||
await logger.flush();
|
||||
assert.equal(storage.writes, 1);
|
||||
assert.equal(logger.getState().pending, 0);
|
||||
assert.equal(states.at(-1).total, 1);
|
||||
time += 2100;
|
||||
logger.record('info', 'lease.wait', '等待同类执行权', { taskId: 'task-1', owner: 'other-window', expiresAt: time + 15000 });
|
||||
assert.equal(logger.getRecords().length, 2, '2秒外连续状态可独立诊断');
|
||||
const cycle = { status: 'waiting', taskId: 'task-1', pageUrl: 'http://10.119.67.7/clientcn/test?token=bad-query#bad-hash', expires: time + 10000, previousOwner: 'worker-2', waitedMs: 3000, password: 'bad', token: 'bad', authorization: 'bad', cookie: 'bad', answers: ['bad-answer'], question: 'bad-question', options: ['bad-option'], bank: { private: 'bad-bank' }, arbitrary: 'bad-arbitrary' };
|
||||
cycle.details = cycle;
|
||||
logger.record('warn', 'task.wait', '失败 URL http://10.119.67.7/clientcn/test?token=bad-query#bad-hash token=bad-token password=bad-password', cycle);
|
||||
const json = await logger.exportJSON(); const parsed = JSON.parse(json);
|
||||
assert.equal(parsed.schema, 1);
|
||||
assert.equal(parsed.records[0].context.url, 'http://10.119.67.7/clientcn/course');
|
||||
assert.equal(parsed.records[2].level, 'warning');
|
||||
assert.equal(parsed.records[2].details.taskId, 'task-1');
|
||||
assert.equal(parsed.records[2].details.pageUrl, 'http://10.119.67.7/clientcn/test');
|
||||
assert.equal(parsed.records[2].details.expires, time + 10000);
|
||||
assert.equal(parsed.records[2].details.previousOwner, 'worker-2');
|
||||
assert.equal(parsed.records[2].details.details, null, '循环引用安全折断');
|
||||
for (const forbidden of ['private', 'secret', 'bad-answer', 'bad-question', 'bad-option', 'bad-bank', 'bad-query', 'bad-hash', 'bad-token', 'bad-password', 'bad-arbitrary']) assert(!json.includes(forbidden), forbidden + '不得被保存');
|
||||
const txt = await logger.exportText();
|
||||
assert(txt.includes('lease.wait')); assert(txt.includes('重复 2 次')); assert(txt.includes('owner'));
|
||||
const reloaded = loggerModule.create({ storage, now: () => time }); await reloaded.ready;
|
||||
assert.equal(reloaded.getRecords().length, 3, '刷新后读取已保存日志');
|
||||
await logger.setEnabled(false);
|
||||
assert.equal(logger.record('error', 'ignored', '关闭后不新增'), false);
|
||||
const off = loggerModule.create({ storage, now: () => time }); await off.ready;
|
||||
assert.equal(off.getState().enabled, false); assert.equal(off.getState().total, 3);
|
||||
await off.setEnabled(true);
|
||||
time += 1; off.record('info', 'page.ready', '第二个页面继续运行'); await off.flush();
|
||||
await logger.flush();
|
||||
assert.equal(logger.getRecords().length, 4, '无待写入时导出也合并其他页面已保存日志');
|
||||
assert.equal(JSON.parse(await logger.exportJSON()).records.length, 4);
|
||||
await off.clear();
|
||||
assert.equal(off.getState().total, 0); assert.equal(off.getState().dropped, 0);
|
||||
await logger.flush();
|
||||
assert.equal(logger.getRecords().length, 0, '其他页面的旧缓存不能在清空后复活');
|
||||
await logger.setEnabled(true); logger.record('info', 'after.clear', '清空后同毫秒新日志'); await logger.flush();
|
||||
assert.equal(logger.getRecords().length, 1);
|
||||
await Promise.all([logger.destroy(), reloaded.destroy(), off.destroy()]);
|
||||
console.log('PASS defaults, persisted reload/toggle, bounded batching/dedup, cross-page merge/clear, onState and JSON/TXT privacy');
|
||||
|
||||
const batchedStore = memory(); const batched = loggerModule.create({ storage: batchedStore }); await batched.ready;
|
||||
batched.record('info', 'page.ready', '自动批量保存');
|
||||
await new Promise(resolve => setTimeout(resolve, 650));
|
||||
assert.equal(batchedStore.writes, 1, '无需手动flush,1秒内自动保存');
|
||||
assert.equal(batched.getState().pending, 0); await batched.destroy();
|
||||
|
||||
const boundedStore = memory(); const bounded = loggerModule.create({ storage: boundedStore, now: () => ++time }); await bounded.ready;
|
||||
for (let index = 0; index < 2510; index++) bounded.record('info', 'task.state', '状态 ' + index, { taskId: 'task-' + index });
|
||||
assert.equal(bounded.getRecords().length, 2000); assert.equal(bounded.getState().pending, 2000);
|
||||
assert.equal(bounded.getState().dropped, 510);
|
||||
await bounded.flush();
|
||||
assert.equal(bounded.getState().dropped, 510, '持久合并不重复计算移除数量');
|
||||
bounded.record('info', 'task.state', '状态 2510', { taskId: 'task-2510' }); await bounded.flush();
|
||||
assert.equal(bounded.getState().dropped, 511);
|
||||
assert.equal(bounded.getRecords()[0].message, '状态 511');
|
||||
await bounded.destroy();
|
||||
console.log('PASS recent 2000 records and unsaved payload cap, accurate single-page eviction count');
|
||||
|
||||
const delayedStore = memory(); let unblock;
|
||||
const originalGet = delayedStore.get;
|
||||
delayedStore.get = async function (key, fallback) { await new Promise(resolve => { if (!unblock) unblock = resolve; else resolve(); }); return originalGet.call(this, key, fallback); };
|
||||
const delayed = loggerModule.create({ storage: delayedStore, now: () => time });
|
||||
const disabled = delayed.setEnabled(false);
|
||||
await Promise.resolve(); unblock(); await disabled;
|
||||
assert.equal(delayed.getState().enabled, false, '初始化前的明确开关优先于慢存储默认值'); await delayed.destroy();
|
||||
let fail = true; let errors = 0; const failedStore = memory(); const realSet = failedStore.set;
|
||||
failedStore.set = async function (key, value) { if (fail) throw new Error('模拟配额不足'); return realSet.call(this, key, value); };
|
||||
const failed = loggerModule.create({ storage: failedStore, now: () => ++time, onError: () => { errors++; } }); await failed.ready;
|
||||
failed.record('error', 'play.failed', '无法继续播放', { reason: 'NotAllowedError' });
|
||||
await failed.flush(); await failed.flush();
|
||||
assert.equal(errors, 1); assert.equal(failed.getState().pending, 1); assert(failed.getState().storageError.includes('配额'));
|
||||
assert(JSON.parse(await failed.exportJSON()).records.some(item => item.event === 'play.failed'), '保存失败仍能导出当前记录');
|
||||
fail = false; await failed.flush();
|
||||
assert.equal(failed.getState().pending, 0); assert.equal(failed.getState().storageError, ''); await failed.destroy();
|
||||
const hostile = loggerModule.create({ storage: memory({ [loggerModule.key]: { schema: 999, records: ['invalid'] } }), onState: () => { throw new Error('UI failed'); } }); await hostile.ready;
|
||||
const object = {}; Object.defineProperty(object, 'status', { enumerable: true, get() { throw new Error('bad getter'); } });
|
||||
assert.doesNotThrow(() => hostile.record('info', 'hostile', '容错', object));
|
||||
await hostile.destroy();
|
||||
console.log('PASS delayed initialization, quota failure preserves export, single error notification, storage recovery and hostile objects');
|
||||
}
|
||||
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -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; });
|
||||
@@ -0,0 +1,295 @@
|
||||
/* 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; });
|
||||
+30
-2
@@ -34,7 +34,7 @@ async function main() {
|
||||
window.qaPanel = YianAssistantPanel.create({
|
||||
onTaskAction: action => qaCalls.actions.push(action),
|
||||
onLearningSetting: (key, value) => qaCalls.settings.push([key, JSON.parse(JSON.stringify(value))]),
|
||||
onLogAction: action => qaCalls.logs.push(action),
|
||||
onLogAction: (action, value) => qaCalls.logs.push(value === undefined ? action : [action, value]),
|
||||
onSearch: (query, settings) => qaCalls.search.push({ query, settings }),
|
||||
onAutoChange: value => qaCalls.auto.push(value),
|
||||
onCopyChange: value => qaCalls.copy.push(value),
|
||||
@@ -236,6 +236,26 @@ async function main() {
|
||||
await host.getByRole('button', { name: '复制日志', exact: true }).click();
|
||||
await host.getByRole('button', { name: '清空显示', exact: true }).click();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.logs), ['copy', 'clear']);
|
||||
const diagnosticSwitch = host.getByRole('switch', { name: '持续记录诊断日志', exact: true });
|
||||
assert(await diagnosticSwitch.isChecked(), '持久诊断记录默认开启');
|
||||
await diagnosticSwitch.uncheck();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.logs.at(-1)), ['record', false]);
|
||||
await page.evaluate(() => qaPanel.setState({ diagnostics: { enabled: false, total: 37, dropped: 2, pending: 1, storage: 'userscript' } }));
|
||||
assert((await host.getByTestId('diagnostic-status').textContent()).includes('已停止新增记录'));
|
||||
assert((await host.getByTestId('diagnostic-status').textContent()).includes('已记录 37 条'));
|
||||
await tab('bank').click(); await tab('console').click();
|
||||
assert.equal(await diagnosticSwitch.isChecked(), false, '切栏保留日志开关');
|
||||
for (const [name, action] of [['导出诊断 JSON', 'exportJson'], ['导出诊断 TXT', 'exportText'], ['清空已保存诊断', 'clearSaved']]) {
|
||||
await host.getByRole('button', { name, exact: true }).click();
|
||||
assert.equal(await page.evaluate(() => qaCalls.logs.at(-1)), action);
|
||||
}
|
||||
await page.evaluate(() => qaPanel.setState({ diagnostics: { storageError: '模拟配额不足' } }));
|
||||
assert((await host.getByTestId('diagnostic-status').textContent()).includes('可先导出当前记录'));
|
||||
assert.equal(await diagnosticSwitch.isChecked(), false, '部分诊断状态更新保留关闭值');
|
||||
await diagnosticSwitch.check();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.logs.at(-1)), ['record', true]);
|
||||
await page.evaluate(() => qaPanel.setState({ diagnostics: { enabled: true, storageError: '', pending: 0 } }));
|
||||
await diagnosticSwitch.scrollIntoViewIfNeeded(); await screenshot('05-desktop-diagnostics.png');
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { logs: Array.from({ length: 510 }, (_, index) => ({ time: '10:00:00', level: 'info', message: '任务状态 ' + index })) } }));
|
||||
await host.getByRole('combobox', { name: '日志级别' }).selectOption('all');
|
||||
assert.equal(await host.locator('.log-entry').count(), 500);
|
||||
@@ -245,7 +265,12 @@ async function main() {
|
||||
assert.equal(await host.getByTestId('global-pause').textContent(), '继续全部');
|
||||
await host.getByTestId('global-pause').click();
|
||||
assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'resume');
|
||||
console.log('PASS filtered/capped logs and shared global pause/resume');
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { status: 'running', waiting: 0, leaseWaiting: 1, video: { status: 'waitingLease' }, tasks: [{ id: 'lease1', type: 'exam', title: '等待中的考试', status: 'waitingLease' }] } }));
|
||||
assert((await host.getByTestId('learning-status').textContent()).includes('1 项等待执行权'));
|
||||
assert(!(await host.getByTestId('learning-status').textContent()).includes('等待人工'));
|
||||
await tab('overview').click();
|
||||
assert((await pane('overview').textContent()).includes('等待执行权(自动重试)'));
|
||||
console.log('PASS filtered/capped runtime logs, persisted diagnostics controls/export/clear and shared global pause/resume; lease waits are distinct from manual waits');
|
||||
|
||||
await tab('overview').click();
|
||||
await page.evaluate(() => qaPanel.setState({ position: { left: 100, top: 20 }, learning: { logs: [], status: 'running' } }));
|
||||
@@ -264,6 +289,9 @@ async function main() {
|
||||
await control.scrollIntoViewIfNeeded(); assert(await control.isVisible()); assert(await control.isChecked());
|
||||
}
|
||||
await screenshot('03b-mobile-playback-switches.png');
|
||||
await tab('console').click(); await diagnosticSwitch.scrollIntoViewIfNeeded();
|
||||
assert(await diagnosticSwitch.isVisible()); assert(await diagnosticSwitch.isChecked());
|
||||
await screenshot('06-mobile-diagnostics.png');
|
||||
await tab('exam').click(); await incompleteSwitch.scrollIntoViewIfNeeded();
|
||||
assert(await incompleteSwitch.isVisible()); assert(await incompleteSwitch.isChecked());
|
||||
await screenshot('03a-mobile-incomplete-switch-on.png');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'attempt-policy.cjs', 'learning-engine.cjs', 'quiz-adapter.cjs', 'exam-controller.cjs', 'panel-tabs.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs', 'workflow.cjs', 'video-workflow.cjs', 'video-exam-workflow.cjs']) {
|
||||
for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'attempt-policy.cjs', 'diagnostic-logger.cjs', 'learning-engine.cjs', 'lease-recovery.cjs', 'quiz-adapter.cjs', 'exam-controller.cjs', 'panel-tabs.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs', 'workflow.cjs', 'video-workflow.cjs', 'video-exam-workflow.cjs', 'diagnostics-workflow.cjs']) {
|
||||
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
|
||||
if (result.status !== 0) process.exit(result.status || 1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user