Files
cc-web/scripts/session-http-load-frontend-unit.js

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; });