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