Files
cc-web/scripts/history-frontend-unit.js

347 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
'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 start = source.indexOf(`function ${name}(`);
assert(start >= 0, `缺少前端函数 ${name}`);
let signatureEnd = start;
let parentheses = 0;
for (let i = source.indexOf('(', start); i < source.length; i += 1) {
if (source[i] === '(') parentheses += 1;
if (source[i] === ')' && --parentheses === 0) { signatureEnd = i; break; }
}
let depth = 0;
for (let i = source.indexOf('{', signatureEnd); i < source.length; i += 1) {
if (source[i] === '{') depth += 1;
if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1);
}
throw new Error(`无法提取 ${name}`);
}
// 仅模拟浏览器节点操作,消息身份、对账、缓存合并及重连分支均执行 app.js 原函数。
class Element {
constructor() {
this.dataset = {};
this.children = [];
this.parentNode = null;
this.scrollTop = 0;
this.id = '';
this.classList = { contains: (name) => this.message?.role === name };
}
get scrollHeight() { return this.children.length * 50; }
get nextSibling() {
if (!this.parentNode) return null;
return this.parentNode.children[this.parentNode.children.indexOf(this) + 1] || null;
}
set innerHTML(value) {
this.children.forEach((child) => { child.parentNode = null; });
this.children = [];
}
insertBefore(node, anchor) {
if (node.fragment) {
[...node.children].forEach((child) => this.insertBefore(child, anchor));
return node;
}
if (node === anchor) return node;
node.remove();
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
assert(index >= 0, '插入锚点必须仍在消息容器中');
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
}
appendChild(node) { return this.insertBefore(node, null); }
removeAttribute(name) { if (name === 'id') this.id = ''; }
remove() {
if (!this.parentNode) return;
const nodes = this.parentNode.children;
nodes.splice(nodes.indexOf(this), 1);
this.parentNode = null;
}
replaceWith(node) {
if (!this.parentNode) return;
this.parentNode.insertBefore(node, this);
this.remove();
}
querySelectorAll(selector) {
return this.children.filter((child) => {
if (selector.includes('[data-session-message="true"]') && child.dataset.sessionMessage !== 'true') return false;
if (selector.includes('[data-message-key]') && !child.dataset.messageKey) return false;
return true;
});
}
querySelector() { return null; }
}
function createHarness() {
const names = [
'stableMessageHash', 'getStableMessageKey', 'getMessageRenderFingerprint',
'mergeIndexedSessionMessages', 'dedupeSessionMessages', 'markSessionMessageElement',
'mergeHistoryChunkIntoCachedSnapshot', 'mergeHistoryChunkIntoSessionLoadSnapshot',
'findRenderedSessionMessageByKey', 'renderMessages', 'reconcileRenderedSessionMessages',
'prependHistoryMessages', 'bindStreamingAssistantMessage', 'commitCompletedAssistantMessage',
'finishGenerating',
];
const resumeStart = source.indexOf("case 'resume_session_result':");
const resumeEnd = source.indexOf("case 'error':", resumeStart);
const doneStart = source.indexOf("case 'done':");
const doneEnd = source.indexOf("case 'system_message':", doneStart);
const generatingStart = source.indexOf("case 'resume_generating':");
const messagesDiv = new Element();
const api = new Function('Element', 'messagesDiv', `
let renderEpoch = 0;
let closedCollabAgentIds = new Set();
let collabAgentStateCache = new Map();
let collabAgentIdsByToolUseId = new Map();
let closedCollabAgentIdsByToolUseId = new Map();
let currentSessionId = 'running-session';
let currentSessionMessageCount = 0;
let isGenerating = false;
let generatingSessionId = null;
let pendingText = '';
let toolGroupCount = 0;
let hasGrouped = false;
const currentAgent = 'codexapp';
const activeToolCalls = new Map();
const activeTodoCallTargets = new Map();
const window = { pendingContentBlocks: [] };
const msgInput = { focus() {} };
let loadedHistorySessionId = '';
let currentCwd = '/tmp/history-test';
const historyLoadMore = null;
const sessionCache = new Map();
const buffers = new Map();
const timers = [];
let outlineUpdates = 0;
let scrollbarUpdates = 0;
const document = {
getElementById(id) { return messagesDiv.children.find((node) => node.id === id) || null; },
querySelector() { return null; },
createDocumentFragment() { const node = new Element(); node.fragment = true; return node; },
};
function setTimeout(callback) { timers.push(callback); }
function collectClosedCollabAgentIds() { return new Set(); }
function clearUserMessageIndex() {}
function updateUserOutlinePanel() { outlineUpdates += 1; }
function updateScrollbar() { scrollbarUpdates += 1; }
function renderPendingNotes() {}
function scrollToBottom() {}
function syncAssistantBranchButton() {}
function isCurrentSessionEvent(msg) { return msg.sessionId === currentSessionId; }
function clearPendingSessionResumeRequest() {}
function setCurrentSessionRunningState() {}
function updateNoteModeUI() {}
function migratePendingNotesToSession() {}
function migrateQueuedMessagesToSession() {}
function scheduleQueuedMessageDrain() {}
function syncAssistantLastSectionButton() {}
function updateGenerationControls() {}
function isNearBottom() { return true; }
function flushRender() {
const stream = document.getElementById('streaming-msg');
if (stream) stream.pendingText = pendingText;
}
function startGenerating() {
isGenerating = true;
const stream = new Element();
stream.id = 'streaming-msg';
stream.dataset.messageKey = 'local-stream';
messagesDiv.appendChild(stream);
}
function getSessionHistoryBuffer(id) { return buffers.get(id) || null; }
function isCompleteSessionSnapshot(snapshot) { return snapshot.complete; }
function cacheSessionSnapshot(snapshot) { sessionCache.set(snapshot.sessionId, { snapshot }); }
function bufferSessionSnapshot(snapshot) { buffers.set(snapshot.sessionId, snapshot); }
${names.map(extractFunction).join('\n')}
function buildMsgElement(message, index) {
const element = new Element();
element.message = JSON.parse(JSON.stringify(message));
markSessionMessageElement(element, index, message);
return element;
}
function resume(msg) {
switch (msg.type) { ${source.slice(resumeStart, resumeEnd)} }
}
function done(msg) {
switch (msg.type) { ${source.slice(doneStart, doneEnd)} }
}
function resumeGenerating(msg) {
switch (msg.type) { ${source.slice(generatingStart, resumeStart)} }
}
return {
${names.join(',')}, resume, done, resumeGenerating, messagesDiv, sessionCache, buffers,
setCount(value) { currentSessionMessageCount = value; },
count() { return currentSessionMessageCount; },
flushTimers() { while (timers.length) timers.shift()(); },
updates() { return { outlineUpdates, scrollbarUpdates }; },
};
`)(Element, messagesDiv);
api.ids = () => messagesDiv.children.map((node) => node.message?.id || node.id);
return api;
}
function message(id, role = 'assistant', content = id) {
return { id, role, content, timestamp: '2026-09-21T00:00:00.000Z' };
}
{
const api = createHarness();
assert.equal(api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1', content: '初稿' }),
api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1', content: '完成' }));
assert.notEqual(api.getStableMessageKey({ role: 'user', turnId: 'turn-1' }),
api.getStableMessageKey({ role: 'assistant', turnId: 'turn-1' }));
assert.equal(api.getStableMessageKey({ id: '已保存', turnId: 'turn-1' }), '已保存');
const before = message('reply');
assert.notEqual(api.getMessageRenderFingerprint(before), api.getMessageRenderFingerprint({ ...before, toolCalls: [{ result: '完成' }] }));
assert.notEqual(api.getMessageRenderFingerprint(before), api.getMessageRenderFingerprint({ ...before, crossConversation: { sourceTitle: '子对话' } }));
}
for (const cached of [false, true]) {
const api = createHarness();
const snapshot = { sessionId: 'history', messages: [message('user', 'user'), message('assistant')], historyBaseIndex: 7, historyTotal: 10 };
api.buffers.set('history', snapshot);
const merge = (messages, baseIndex) => cached
? api.mergeHistoryChunkIntoCachedSnapshot('history', messages, { baseIndex, total: 10, cursor: 3 })
: api.mergeHistoryChunkIntoSessionLoadSnapshot(snapshot, messages, { baseIndex, total: 10 });
merge([message('older')], 7);
assert.deepEqual(snapshot.messages.map((item) => item.id), ['user', 'older', 'assistant'], '相同下标不能吞掉不同 ID');
merge([message('older')], 7);
assert.equal(snapshot.messages.length, 3, '重复分页不能重复插入');
assert.equal(snapshot.historyMessagePositions.assistant, 8, '碰撞后的再次分页不能挤动原下标');
const updated = { ...message('assistant', 'assistant', '新正文'), toolCalls: [{ result: '工具结果' }], crossConversation: { reply: true, sourceTitle: '负责人' } };
merge([updated], 3);
assert.deepEqual(snapshot.messages.map((item) => item.id), ['assistant', 'user', 'older'], '同 ID 的新位置必须生效');
assert.deepEqual(snapshot.messages[0], updated, '同 ID 的正文、工具和来源必须更新');
assert.equal(snapshot.historyBaseIndex, 3);
}
{
const api = createHarness();
api.renderMessages([message('user', 'user'), message('assistant'), message('assistant', 'assistant', '最终回复')], { immediate: true, baseIndex: 7 });
assert.deepEqual(api.ids(), ['user', 'assistant'], '初始渲染按稳定 ID 去重');
assert.equal(api.messagesDiv.children[1].message.content, '最终回复');
api.prependHistoryMessages([message('older')], { baseIndex: 7 });
assert.deepEqual(api.ids(), ['user', 'older', 'assistant'], '同下标的分页消息不得丢失');
api.prependHistoryMessages([message('older')], { baseIndex: 7 });
assert.equal(api.messagesDiv.children.length, 3);
const updated = { ...message('assistant', 'assistant', '最终回复'), toolCalls: [{ id: 'tool', result: '已更新' }], crossConversation: { reply: true, sourceTitle: '负责人' } };
const counts = api.updates();
api.prependHistoryMessages([updated], { baseIndex: 3 });
assert.deepEqual(api.ids(), ['assistant', 'user', 'older'], '同 ID 跨下标移动必须重排 DOM');
assert.deepEqual(api.messagesDiv.children[0].message, updated, '正文相同时也更新工具和来源');
assert.equal(api.messagesDiv.children[0].dataset.messageIndex, '3');
assert(api.updates().outlineUpdates > counts.outlineUpdates, '只更新消息也需要刷新目录');
}
{
const api = createHarness();
const initial = Array.from({ length: 35 }, (_, index) => message(`batch-${index}`));
api.renderMessages(initial);
const latest = { ...initial[3], content: '重连期间更新的内容', toolCalls: [{ result: '新结果' }] };
api.reconcileRenderedSessionMessages([latest], { baseIndex: 3 });
api.flushTimers();
assert.deepEqual(api.ids(), initial.map((item) => item.id), '延迟渲染与重连对账交错不能重复插入');
assert.equal(api.messagesDiv.children[3].message.content, latest.content, '延迟批次不能把更新恢复成旧内容');
}
{
const api = createHarness();
const user = message('question', 'user', '没参考其他的页面么,WMSA51 是你这么写的么?');
const architecture = message('architecture', 'assistant', '页面职责和流程边界需要返工');
const reply = { ...message('reply'), crossConversation: { reply: true, sourceTitle: '旧来源' } };
api.renderMessages([architecture, reply], { immediate: true, baseIndex: 20 });
const stream = new Element();
stream.id = 'streaming-msg';
stream.pendingText = '当前正在生成的消息';
api.messagesDiv.appendChild(stream);
const refreshedReply = { ...reply, crossConversation: { reply: true, sourceTitle: '子对话负责人' }, toolCalls: [{ result: '验收完成' }] };
const snapshot = { sessionId: 'running-session', messages: [architecture, reply], historyBaseIndex: 20, historyTotal: 23 };
api.buffers.set('running-session', snapshot);
const resume = {
type: 'resume_session_result', sessionId: 'running-session', isRunning: true,
historyBaseIndex: 20, historyTotal: 23, messages: [user, architecture, refreshedReply],
};
api.resume(resume);
api.resume(resume);
assert.deepEqual(api.ids(), ['question', 'architecture', 'reply', 'streaming-msg']);
assert.strictEqual(api.messagesDiv.children.at(-1), stream, '运行中重连必须保留原流式节点');
assert.equal(stream.pendingText, '当前正在生成的消息');
assert.equal(api.messagesDiv.children[2].message.crossConversation.sourceTitle, '子对话负责人');
const changed = { ...refreshedReply, content: '回传内容更新', toolCalls: [{ result: '新结果' }] };
api.resume({ ...resume, messages: [user, architecture, changed] });
assert.equal(api.messagesDiv.children[2].message.content, '回传内容更新', '同数量同 ID 的重连也必须更新');
assert.equal(snapshot.messages.find((item) => item.id === 'reply').toolCalls[0].result, '新结果');
assert.equal(api.messagesDiv.children[0].message.role, 'user');
assert.equal(api.messagesDiv.children[2].message.role, 'assistant');
}
{
const api = createHarness();
const question = message('question', 'user');
const completed = { ...message('turn-reply'), toolCalls: [{ id: 'tool', result: '最终结果' }] };
api.renderMessages([question], { immediate: true });
api.setCount(1);
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', assistantMessageId: completed.id, turnId: 'turn-1', text: '生成中的回复' });
const stream = api.messagesDiv.children.at(-1);
assert.equal(stream.dataset.messageKey, completed.id, '恢复流式状态必须绑定持久化助手 ID');
assert.equal(stream.dataset.turnId, 'turn-1');
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 1 });
assert.deepEqual(api.ids(), ['question', 'turn-reply']);
assert.deepEqual(api.messagesDiv.children[1].message, completed, '完成事件必须采用规范化助手消息');
assert.equal(api.count(), 2);
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 1 });
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '重复完成事件不能重复增加助手');
assert.equal(api.count(), 2, '重复完成事件不能增加消息计数');
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: false, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '完成后的重连不得再次插入助手');
}
{
const api = createHarness();
const question = message('question', 'user');
const completed = message('turn-reply', 'assistant', '快照中的最终回复');
api.renderMessages([question, completed], { immediate: true });
api.setCount(2);
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', assistantMessageId: completed.id, turnId: 'turn-2', text: '仍在生成' });
const stream = api.messagesDiv.children.at(-1);
assert.deepEqual(api.ids(), ['question', 'streaming-msg'], '相同助手 ID 的历史气泡与 stream 只能显示一份');
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: true, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
assert.deepEqual(api.ids(), ['question', 'streaming-msg'], '流式过程中重复快照不能再建立同 ID 历史气泡');
assert.strictEqual(api.messagesDiv.children.at(-1), stream, '对账仍须保留运行中的原节点');
api.resume({ type: 'resume_session_result', sessionId: 'running-session', isRunning: false, messages: [question, completed], historyBaseIndex: 0, historyTotal: 2 });
assert.deepEqual(api.ids(), ['question', 'turn-reply'], '停止运行的恢复结果须把 stream 转成同 ID 最终助手');
assert.equal(api.count(), 2);
}
{
const api = createHarness();
const completed = message('already-saved');
api.renderMessages([completed], { immediate: true });
api.setCount(1);
api.resumeGenerating({ type: 'resume_generating', sessionId: 'running-session', text: '尚未绑定 ID 的流式输出' });
assert.equal(api.messagesDiv.children.length, 2);
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: { ...completed, content: '规范化最终回复' }, assistantMessageIndex: 0 });
assert.deepEqual(api.ids(), ['already-saved'], '完成事件需移除与已保存消息重复的 stream');
assert.equal(api.messagesDiv.children[0].message.content, '规范化最终回复');
assert.equal(api.count(), 1);
}
{
const api = createHarness();
const completed = message('without-stream');
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: completed, assistantMessageIndex: 3 });
assert.deepEqual(api.ids(), ['without-stream'], '丢失流式节点时完成事件仍可补回最终消息');
assert.equal(api.count(), 4);
api.done({ type: 'done', sessionId: 'running-session', assistantMessage: { ...completed, content: '最终内容更新' }, assistantMessageIndex: 3 });
assert.equal(api.messagesDiv.children[0].message.content, '最终内容更新');
assert.equal(api.count(), 4);
}
console.log('前端历史单测通过:稳定 ID、分页、重连、流式身份绑定与完成去重');