170 lines
8.4 KiB
JavaScript
170 lines
8.4 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
|
|
|
|
function extractFunction(name) {
|
|
const match = new RegExp(`(?:async )?function ${name}\\(`).exec(source);
|
|
assert(match, '缺少生产函数 ' + name);
|
|
let parentheses = 0;
|
|
let signatureEnd;
|
|
for (let index = source.indexOf('(', match.index); index < source.length; index++) {
|
|
if (source[index] === '(') parentheses++;
|
|
if (source[index] === ')' && --parentheses === 0) { signatureEnd = index; break; }
|
|
}
|
|
let depth = 0;
|
|
for (let index = source.indexOf('{', signatureEnd); index < source.length; index++) {
|
|
if (source[index] === '{') depth++;
|
|
if (source[index] === '}' && --depth === 0) return source.slice(match.index, index + 1);
|
|
}
|
|
throw new Error('函数边界异常 ' + name);
|
|
}
|
|
|
|
function createHarness() {
|
|
const network = [];
|
|
const api = new Function('network', 'AbortController', 'URLSearchParams', `
|
|
let currentSessionId = 'original';
|
|
let currentSessionViewRequestId = 'view-original';
|
|
let currentSessionHistoryRevision = 'r0';
|
|
let currentSessionSubscriptionRequestId = '';
|
|
let sessionLoadAbortController = null;
|
|
let activeSessionLoad = null;
|
|
let activeHistoryPageRequest = null;
|
|
let activeOutlineHistoryRequest = null;
|
|
let activeOutlineTargetRequest = null;
|
|
let currentHistoryState = {};
|
|
let currentOutlineHistoryState = {};
|
|
let pendingSessionResumeRequest = null;
|
|
let ws = null;
|
|
let wsAuthenticated = false;
|
|
const authToken = 'test-http-token';
|
|
const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45000;
|
|
const sessionCompletionVersions = new Map();
|
|
const events = [], errors = [], sent = [], timers = new Map();
|
|
let sequence = 0;
|
|
function setTimeout(callback, ms) { const id = ++sequence; timers.set(id, {callback, ms}); return id; }
|
|
function clearTimeout(id) { timers.delete(id); }
|
|
function createSessionSwitchRequestId(id) { return 'query-' + ++sequence + '-' + id; }
|
|
function clearSessionLoadRequestTimer() {}
|
|
function renderHistoryLoadControl() {}
|
|
function setSessionLoading(id, options) {
|
|
sessionLoadAbortController?.abort(); sessionLoadAbortController = null;
|
|
activeSessionLoad = id ? { sessionId:id, ...options } : null;
|
|
}
|
|
function clearSessionLoading() { setSessionLoading(null, {}); }
|
|
function handleServerMessage(payload) {
|
|
if (payload.throwRender) throw new Error('夹具渲染失败');
|
|
events.push(payload);
|
|
if (payload.type === 'session_info') { currentSessionId = payload.sessionId; activeSessionLoad = null; }
|
|
if (payload.type === 'session_history_chunk') activeHistoryPageRequest = null;
|
|
}
|
|
function appendError(message) { errors.push(message); }
|
|
function showToast(message) { errors.push(message); }
|
|
function finishGenerating(id) { events.push({ type:'finished', sessionId:id }); }
|
|
function requestOutlineMessageTarget(index) { events.push({ type:'target', index }); }
|
|
function connect() {}
|
|
function fetch(url, options) { return new Promise((resolve, reject) => network.push({url,options,resolve,reject})); }
|
|
${['requestSessionLoad','fetchSessionQuery','fetchHistoryPage','requestSessionResume','flushPendingSessionResume'].map(extractFunction).join('\n')}
|
|
return {
|
|
requestSessionLoad, fetchSessionQuery, events, errors, sent, timers,
|
|
state: () => ({ currentSessionId, currentSessionViewRequestId, activeSessionLoad, sessionLoadAbortController }),
|
|
complete: id => sessionCompletionVersions.set(id, (sessionCompletionVersions.get(id) || 0) + 1),
|
|
page: (id, requestId) => { activeHistoryPageRequest = {sessionId:id, requestId};
|
|
fetchHistoryPage(activeHistoryPageRequest, 24); return activeHistoryPageRequest; },
|
|
socket: open => { wsAuthenticated = open; ws = open ? {readyState:1,send:data=>sent.push(JSON.parse(data))} : null; },
|
|
};
|
|
`)(network, AbortController, URLSearchParams);
|
|
return { ...api, network };
|
|
}
|
|
function respond(api, index, overrides = {}, status = 200) {
|
|
const request = api.network[index];
|
|
const url = new URL(request.url, 'http://localhost');
|
|
const isPage = url.pathname.endsWith('/history');
|
|
const payload = { type: isPage ? 'session_history_chunk' : 'session_info',
|
|
requestId: url.searchParams.get('requestId'), sessionId: url.pathname.split('/')[3],
|
|
messages: [], historyRevision: 'r1', ...overrides };
|
|
request.resolve({ ok: status === 200, json: async () => payload });
|
|
}
|
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
|
|
|
async function runFrontendUnit() {
|
|
{
|
|
const api = createHarness();
|
|
api.requestSessionLoad('A');
|
|
assert.equal(api.network.length, 1, '没有 WebSocket 也必须立即查询 HTTP');
|
|
assert.equal(api.network[0].options.headers.Authorization, 'Bearer test-http-token');
|
|
api.requestSessionLoad('B', { requestId: 'new-B' });
|
|
assert(api.network[0].options.signal.aborted, '连续切换必须取消旧读取');
|
|
respond(api, 1);
|
|
await flush();
|
|
respond(api, 0, { runtime: { type: 'resume_generating', sessionId: 'A', text: '迟到输出' } });
|
|
await flush();
|
|
assert.equal(api.state().currentSessionId, 'B');
|
|
assert.deepEqual(api.events.map(event => event.type), ['session_info'], '忽略取消后仍然抵达的旧响应和旧运行输出');
|
|
assert.equal(api.sent.length, 0);
|
|
}
|
|
{
|
|
const api = createHarness();
|
|
api.requestSessionLoad('A', { requestId: 'old-A' });
|
|
api.requestSessionLoad('A', { requestId: 'new-A' });
|
|
respond(api, 0); await flush();
|
|
assert.equal(api.events.length, 0, '同一会话也必须隔离请求代次');
|
|
respond(api, 1, { runtime: { type: 'resume_generating', sessionId: 'A', text: '完整输出' } });
|
|
await flush();
|
|
assert.equal(api.events[1].text, '完整输出');
|
|
assert(!api.events.some(event => event.type === 'target'), '未指定定位不能被 null 转换成第0条');
|
|
const page = api.page('A', 'older');
|
|
api.requestSessionLoad('B', { requestId: 'B' });
|
|
assert(page.controller.signal.aborted);
|
|
respond(api, 2); await flush();
|
|
assert(!api.events.some(event => event.type === 'session_history_chunk'), '新视图必须拒绝旧分页');
|
|
respond(api, 3); await flush();
|
|
}
|
|
{
|
|
const api = createHarness();
|
|
api.socket(true);
|
|
api.requestSessionLoad('A', { requestId: 'before-done' });
|
|
api.complete('A');
|
|
respond(api, 0, { isRunning: true }); await flush();
|
|
assert.equal(api.events.length, 0, '已收到完成事件后不能应用较早的运行快照');
|
|
assert.equal(api.network.length, 2, '完成事件与 HTTP 交错时应重新读取持久化终态');
|
|
respond(api, 1, { isRunning: false }); await flush();
|
|
assert.equal(api.events[0].isRunning, false);
|
|
assert.deepEqual(api.sent.map(msg => msg.type), ['subscribe_session']);
|
|
}
|
|
{
|
|
const api = createHarness();
|
|
for (const [overrides, message] of [
|
|
[{ requestId: 'unmatched' }, /响应与请求不匹配/],
|
|
[{ type: 'invalid_payload' }, /响应格式无效/],
|
|
[{ throwRender: true }, /夹具渲染失败/],
|
|
]) {
|
|
const index = api.network.length;
|
|
api.requestSessionLoad('invalid');
|
|
respond(api, index, overrides); await flush();
|
|
assert.match(api.errors.at(-1), message);
|
|
assert.equal(api.state().activeSessionLoad, null, '响应或渲染异常不能留下永久加载');
|
|
}
|
|
}
|
|
{
|
|
const api = createHarness();
|
|
api.requestSessionLoad('broken');
|
|
respond(api, 0, { message: '夹具历史读取失败' }, 500); await flush();
|
|
assert.equal(api.errors[0], '夹具历史读取失败');
|
|
assert.equal(api.state().activeSessionLoad, null);
|
|
assert.equal(api.state().currentSessionId, 'original', '失败不能清空当前视图');
|
|
api.requestSessionLoad('timeout');
|
|
for (const timer of api.timers.values()) timer.callback();
|
|
assert(api.network[1].options.signal.aborted);
|
|
api.network[1].reject(new Error('aborted')); await flush();
|
|
assert.match(api.errors.at(-1), /HTTP 请求超时/);
|
|
assert.equal(api.timers.size, 0, '成功、失败和超时均须清理请求计时器');
|
|
}
|
|
console.log('HTTP 前端单测通过:无 WS 读取、取消、跨会话/同会话迟到隔离、分页取消、运行快照、完成交错、500及超时恢复');
|
|
}
|
|
|
|
module.exports = { runFrontendUnit };
|
|
if (require.main === module) runFrontendUnit().catch(error => { console.error(error); process.exitCode = 1; });
|