Files
cc-web/scripts/history-browser-check.js
shiyue 69b6db0f5b 修复完整会话历史与长气泡交互并更新 CentOS 7 发布包
提交当前工作区全部源码、回归脚本、任务记录及重新构建的 Bun baseline 发布包。发布单文件 MCP 初始化及前端资源一致性验证通过。
2026-10-10 23:40:16 +08:00

458 lines
29 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';
// 使用隔离会话、原生 rollout 和 mock 运行时,在真实 Firefox 中验证历史恢复。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { WebSocket, WebSocketServer } = require('ws');
const { createCodexRolloutStore } = require('../lib/codex-rollouts');
const REPO = path.resolve(__dirname, '..');
const FIREFOX = process.env.CC_WEB_BROWSER_FIREFOX || '/snap/firefox/current/usr/lib/firefox/firefox';
const GECKODRIVER = process.env.CC_WEB_BROWSER_GECKODRIVER || '/snap/firefox/current/usr/lib/firefox/geckodriver';
const QUESTION = '没参考其他的页面么,WMSA51 是你这么写的么?';
const ARCHITECTURE = '先按项目规范核对现有页面、任务票和负责会话,再把样式返工交回原实现负责人。';
const BOUNDARY = '这不是单纯的样式问题,而是 T-003 的页面职责和流程边界做错了。';
const STREAM = '当前正在生成的消息:正在核对页面职责与流程边界。';
const PLAN_STEPS = ['读取历史计划状态', '检查实时计划卡片', '验证刷新后仍显示'];
const PLAN_SOURCE = process.env.CC_WEB_HISTORY_PLAN_SOURCE === 'mcp' ? 'mcp' : 'native';
const SESSION_ID = '11111111-2222-4333-8444-555555555555';
const THREAD_ID = '11111111-2222-4333-8444-666666666666';
const PASSWORD = 'HistoryBrowserFixture!234';
const MCP_TOKEN = 'HistoryBrowserMcpFixture!234';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
server.close(() => resolve(port));
});
});
}
async function waitFor(predicate, description, timeout = 12000) {
const start = Date.now();
let lastError;
while (Date.now() - start < timeout) {
try { const result = await predicate(); if (result) return result; } catch (error) { lastError = error; }
await sleep(80);
}
throw new Error(`${description}${lastError ? `:${lastError.message}` : ''}`);
}
async function stopChild(child) {
if (!child || child.exitCode !== null || child.signalCode) return;
const exited = new Promise((resolve) => child.once('exit', resolve));
child.kill('SIGTERM');
await Promise.race([exited, sleep(1000)]);
if (child.exitCode === null && !child.signalCode) {
child.kill('SIGKILL');
await exited;
}
}
class FirefoxDriver {
constructor(port) { this.port = port; this.sessionId = ''; }
async request(method, endpoint, body) {
const response = await fetch(`http://127.0.0.1:${this.port}${endpoint}`, {
method, headers: body === undefined ? {} : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json();
if (!response.ok || payload.value?.error) throw new Error(JSON.stringify(payload.value || payload));
return payload.value;
}
async start() {
const value = await this.request('POST', '/session', { capabilities: { alwaysMatch: {
browserName: 'firefox', 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'] },
} } });
this.sessionId = value.sessionId;
await this.request('POST', `/session/${this.sessionId}/window/rect`, { width: 1280, height: 1050 });
return value.capabilities;
}
execute(script, args = []) { return this.request('POST', `/session/${this.sessionId}/execute/sync`, { script, args }); }
navigate(url) { return this.request('POST', `/session/${this.sessionId}/url`, { url }); }
refresh() { return this.request('POST', `/session/${this.sessionId}/refresh`, {}); }
async screenshot(file) {
const data = await this.request('GET', `/session/${this.sessionId}/screenshot`);
fs.writeFileSync(file, Buffer.from(data, 'base64'));
}
async close() { if (this.sessionId) await this.request('DELETE', `/session/${this.sessionId}`); }
}
function createFixture(root) {
const home = path.join(root, 'fixture-home');
const sessions = path.join(root, 'sessions');
const nativeDir = path.join(home, '.codex', 'sessions', '2026', '09', '21');
for (const folder of [sessions, nativeDir, path.join(root, 'config'), path.join(root, 'logs')]) fs.mkdirSync(folder, { recursive: true });
const rows = [{ type: 'session_meta', payload: { id: THREAD_ID, cwd: root } }];
function turn(index, user, textParts) {
const stamp = new Date(Date.UTC(2026, 8, 21, 0, 0, index)).toISOString();
const turnId = `browser-turn-${index}`;
rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'task_started', turn_id: turnId } });
rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'user_message', message: user, id: `browser-user-${index}` } });
textParts.forEach((text) => rows.push({ type: 'response_item', timestamp: stamp, payload: {
type: 'message', role: 'assistant', internal_chat_message_metadata_passthrough: { turn_id: turnId },
content: [{ type: 'output_text', text }],
} }));
rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'task_complete', turn_id: turnId } });
}
for (let index = 0; index < 72; index++) turn(index, `早期问题 ${index}`, [`早期回答 ${index}`]);
const store = createCodexRolloutStore({ codexSessionsDir: nativeDir, sessionsDir: sessions, normalizeSession: (v) => v, sanitizeToolInput: (_name, v) => v });
const prefix = store.parseCodexRolloutLines(rows.map((row) => JSON.stringify(row))).messages;
const snapshot = prefix.slice(-64);
snapshot.push(
{ id: 'snapshot-latest-user', clientMessageId: 'snapshot-latest-user', role: 'user', timestamp: '2026-09-21T01:00:00.000Z', content: QUESTION },
{ id: 'snapshot-architecture', role: 'assistant', timestamp: '2026-09-21T01:00:01.000Z', content: ARCHITECTURE },
{ id: 'snapshot-boundary', role: 'assistant', timestamp: '2026-09-21T01:00:02.000Z', content: BOUNDARY },
{ id: 'snapshot-child-reply', role: 'assistant', timestamp: '2026-09-21T01:00:03.000Z', content: '已按现有 WMSA51 页面完成职责核对。', crossConversation: {
reply: true, replyToRequestId: 'fixture-child-request', sourceTitle: '页面实现负责人', sourceSessionId: '77777777-2222-4333-8444-555555555555',
} },
);
turn(73, QUESTION, Array.from({ length: 48 }, (_, index) => `native 不应覆盖当前快照的助手片段 ${index}`));
for (let index = 74; index < 242; index++) turn(index, `native 内部输入 ${index}`, [`native 内部回答 ${index}`]);
rows.push({ type: 'event_msg', timestamp: '2026-09-21T02:00:00.000Z', payload: {
type: 'user_message', message: 'native 内部输入尾部', turn_id: 'browser-native-tail',
} });
const rollout = path.join(nativeDir, `rollout-2026-09-21T00-00-00-${THREAD_ID}.jsonl`);
fs.writeFileSync(rollout, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
const notice = {
role: 'system', content: '历史消息过多,cc-web 本地快照只保留最近 68 条……', ccwebPersistenceNotice: true,
snapshotMessageCount: 68, snapshotFirstMessageId: snapshot[0].id, snapshotLastMessageId: snapshot.at(-1).id,
nativeThreadId: THREAD_ID, truncatedAt: '2026-09-21T01:00:04.000Z',
};
fs.writeFileSync(path.join(sessions, `${SESSION_ID}.json`), JSON.stringify({
id: SESSION_ID, title: '历史恢复浏览器夹具', agent: 'codexapp', codexAppThreadId: THREAD_ID, cwd: root,
created: '2026-09-21T00:00:00.000Z', updated: '2026-09-21T01:00:04.000Z', permissionMode: 'yolo',
historySnapshotBaseIndex: 80, historySnapshotCount: 68, messages: [notice, ...snapshot],
}, null, 2));
// 只在隔离子进程中替换 homedir;不覆盖真实 HOME / CODEX_HOME,也不访问生产 rollout。
const preload = path.join(root, 'isolated-home.cjs');
fs.writeFileSync(preload, `
const fs = require('node:fs');
const Module = require('node:module');
const originalLoader = Module._extensions['.js'];
require('node:os').homedir = () => ${JSON.stringify(home)};
Module._extensions['.js'] = (module, filename) => {
if (filename !== ${JSON.stringify(path.join(REPO, 'server.js'))}) return originalLoader(module, filename);
const source = fs.readFileSync(filename, 'utf8');
const original = "const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');";
if (!source.includes(original)) throw new Error('原生历史目录注入点已变更,请更新浏览器夹具');
module._compile(source.replace(original, 'const CODEX_SESSIONS_DIR = ' + ${JSON.stringify(JSON.stringify(path.join(home, '.codex', 'sessions')))} + ';'), filename);
};
`);
const mock = path.join(root, 'mock-codex.js');
const completeFlag = path.join(root, 'complete-turn');
const existingMock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8');
const timerCode = 'thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);';
assert(existingMock.includes(timerCode), 'mock 运行时启动入口已变更');
const planEntries = PLAN_STEPS.map((step, index) => ({
step, status: ['completed', PLAN_SOURCE === 'mcp' ? 'in_progress' : 'inProgress', 'pending'][index],
}));
const planNotification = PLAN_SOURCE === 'mcp'
? `const planItem={type:'mcpToolCall',id:'browser-plan',server:'ccweb',tool:'ccweb_update_plan',arguments:{plan:${JSON.stringify(planEntries)}}};
send({method:'item/started',params:{threadId:thread.id,turnId,item:planItem}});
send({method:'item/completed',params:{threadId:thread.id,turnId,item:planItem}});`
: `send({method:'turn/plan/updated',params:{threadId:thread.id,turnId,plan:${JSON.stringify(planEntries)}}});`;
const browserMock = existingMock.replace(timerCode, `
${planNotification}
send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'browser-stream',delta:${JSON.stringify(STREAM)}}});
thread.timer = setInterval(() => {
if (!require('fs').existsSync(${JSON.stringify(completeFlag)})) return;
clearInterval(thread.timer);
send({method:'turn/completed',params:{threadId:thread.id,turn:{id:turnId,status:'completed',items:[]}}});
thread.activeTurnId = null;
thread.timer = null;
}, 80);
`).replace("path.join(__dirname, 'mock-codex.js')", JSON.stringify(path.join(REPO, 'scripts/mock-codex.js')));
fs.writeFileSync(mock, browserMock, { mode: 0o755 });
return {
home, sessions, rollout, preload, mock, completeFlag, snapshotCount: snapshot.length,
historyIds: [...prefix, ...snapshot.slice(-4)].map((message) => message.id),
nativeCount: store.parseCodexRolloutLines(rows.map((row) => JSON.stringify(row))).messages.length,
};
}
async function startRelay(port, targetPort, evidence) {
const relay = http.createServer((request, response) => {
const upstream = http.request({ hostname: '127.0.0.1', port: targetPort, path: request.url, method: request.method, headers: request.headers }, (result) => {
response.writeHead(result.statusCode, result.headers); result.pipe(response);
});
upstream.on('error', () => response.destroy()); request.pipe(upstream);
});
const sockets = new Set();
const wsServer = new WebSocketServer({ noServer: true });
let connections = 0;
relay.on('upgrade', (request, socket, head) => wsServer.handleUpgrade(request, socket, head, (client) => {
connections++;
sockets.add(client);
const upstream = new WebSocket(`ws://127.0.0.1:${targetPort}/ws`);
const pending = [];
upstream.on('open', () => { pending.splice(0).forEach((data) => upstream.send(data)); });
client.on('message', (data) => { if (upstream.readyState === WebSocket.OPEN) upstream.send(data); else pending.push(data); });
upstream.on('message', (data) => {
try {
const msg = JSON.parse(String(data));
if (['session_info', 'resume_session_result', 'session_history_chunk'].includes(msg.type)) evidence.protocol.push({
type: msg.type, historySource: msg.historySource, historyTotal: msg.historyTotal,
historyAvailable: msg.historyAvailable, isRunning: msg.isRunning,
ids: msg.messages?.map((message) => message.id),
messages: msg.messages?.map((message, index) => ({
id: message.id,
index: Number.isFinite(Number(msg.historyBaseIndex)) ? Number(msg.historyBaseIndex) + index : null,
})),
});
} catch {}
if (client.readyState === WebSocket.OPEN) client.send(data, { binary: false });
});
client.on('close', () => { sockets.delete(client); upstream.terminate(); });
upstream.on('close', () => { if (client.readyState === WebSocket.OPEN) client.close(); });
upstream.on('error', () => client.terminate());
client.on('error', () => upstream.terminate());
}));
await new Promise((resolve) => relay.listen(port, '127.0.0.1', resolve));
return {
connectionCount: () => connections,
disconnect: () => { for (const socket of sockets) socket.terminate(); },
close: async () => { for (const socket of sockets) socket.terminate(); wsServer.close(); relay.closeAllConnections(); await new Promise((resolve) => relay.close(resolve)); },
};
}
async function capture(driver, stage, root, requireStream = true) {
await waitFor(() => driver.execute(`return [...document.querySelectorAll('#messages .msg')].some(node => node.textContent.includes(arguments[0]))`, [requireStream ? STREAM : QUESTION]), `${stage} 消息未恢复`);
const result = await driver.execute(`return {
messages: [...document.querySelectorAll('#messages > .msg')].map(node => ({
role: node.classList.contains('user') ? 'user' : node.classList.contains('assistant') ? 'assistant' : 'system',
id: node.dataset.messageId || '', key: node.dataset.messageKey || '', text: node.innerText,
source: node.querySelector('.cross-conversation-label')?.innerText || '', streaming: node.id === 'streaming-msg',
})), running: document.querySelector('#chat-runtime-state')?.textContent || '',
};`);
const find = (text) => result.messages.filter((message) => message.text.includes(text));
for (const text of [QUESTION, ARCHITECTURE, BOUNDARY]) assert.equal(find(text).length, 1, `${stage}:消息重复或消失:${text}`);
const userIndex = result.messages.findIndex((message) => message.text.includes(QUESTION));
assert.equal(result.messages[userIndex].role, 'user');
assert(result.messages[userIndex + 1].text.includes(ARCHITECTURE), `${stage}:用户与对应回复失去邻接关系`);
assert(result.messages[userIndex + 2].text.includes(BOUNDARY));
assert.equal(result.messages[userIndex + 3].role, 'assistant');
assert.equal(result.messages[userIndex + 3].source, '来自「页面实现负责人」的回复');
assert(!result.messages.some((message) => message.text.includes('native 不应覆盖') || message.text.includes('native 内部输入')));
if (requireStream) assert.equal(find(STREAM).length, 1, `${stage}:当前输出重复或丢失`);
if (requireStream) {
const plan = await driver.execute(`return [...document.querySelectorAll('#messages .tool-call[data-tool-kind="todo_list"]')].map(node => ({
open: node.open, steps: [...node.querySelectorAll('.todo-text')].map(item => item.textContent),
visible: node.getBoundingClientRect().height > 0 && getComputedStyle(node).display !== 'none',
}));`);
assert.equal(plan.length, 1, `${stage}:原生 update_plan 没有渲染为唯一 PlanList 卡片`);
assert(plan[0].open && plan[0].visible, `${stage}:PlanList 卡片存在但内容被折叠或隐藏`);
assert.deepEqual(plan[0].steps, PLAN_STEPS, `${stage}:PlanList 条目缺失或顺序错误`);
}
const keys = result.messages.map((message) => message.key).filter(Boolean);
assert.equal(new Set(keys).size, keys.length, `${stage}:稳定 ID 重复渲染`);
await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`);
// 等待滚动和入场动画完成后,再验证截图中的实际可见内容。
result.visibleTail = await waitFor(async () => {
const tail = await driver.execute(`return [...document.querySelectorAll('#messages > .msg')].slice(-6).map(node => {
const box=node.getBoundingClientRect(), style=getComputedStyle(node);
return {text:node.innerText, top:box.top, bottom:box.bottom, height:box.height, opacity:style.opacity, visibility:style.visibility, display:style.display};
});`);
const question = tail.find((message) => message.text.includes(QUESTION));
return question && question.top >= 0 && question.height > 0 && Number(question.opacity) > 0.9 ? tail : null;
}, `${stage}:目标问题没有实际显示在视口`);
const visibleQuestion = result.visibleTail.find((message) => message.text.includes(QUESTION));
assert(visibleQuestion && visibleQuestion.top >= 0 && visibleQuestion.height > 0 && Number(visibleQuestion.opacity) > 0.9,
`${stage}:目标问题没有实际显示在视口`);
await driver.screenshot(path.join(root, `${stage}.png`));
return result;
}
async function main() {
assert(fs.existsSync(FIREFOX) && fs.existsSync(GECKODRIVER), '需要本机 Firefox 和 geckodriver,可通过 CC_WEB_BROWSER_* 指定');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-history-browser-'));
const fixture = createFixture(root);
const evidence = { root, fixture, protocol: [], stages: {}, kind: '真实 server.js / Firefox / 隔离持久化与 native rollout / mock Codex App' };
const [serverPort, relayPort, driverPort] = await Promise.all([freePort(), freePort(), freePort()]);
let server, gecko, relay, driver;
let rolloutHidden = false;
try {
const launchServer = (logName = 'server.log') => {
const child = spawn(process.execPath, ['-r', fixture.preload, path.join(REPO, 'server.js')], {
cwd: REPO, env: { ...process.env, PORT: String(serverPort), CC_WEB_PASSWORD: PASSWORD,
CC_WEB_INTERNAL_MCP_TOKEN: MCP_TOKEN,
CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_SESSIONS_DIR: fixture.sessions, CC_WEB_LOGS_DIR: path.join(root, 'logs'),
CODEX_HOME: path.join(fixture.home, '.codex'),
CODEX_PATH: fixture.mock, CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'), CC_WEB_TASK_BOARD_ENABLED: '0',
}, stdio: ['ignore', 'pipe', 'pipe'],
});
const log = fs.createWriteStream(path.join(root, logName));
child.stdout.pipe(log); child.stderr.pipe(log);
child.once('exit', () => log.end());
return child;
};
server = launchServer();
await waitFor(async () => (await fetch(`http://127.0.0.1:${serverPort}/`)).ok, '隔离服务启动失败');
const mcpResponse = await fetch(`http://127.0.0.1:${serverPort}/api/internal/mcp/stream?sourceSessionId=${SESSION_ID}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-cc-web-mcp-token': MCP_TOKEN },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: {
name: 'ccweb_update_plan', arguments: { plan: [{ step: PLAN_STEPS[0], status: 'completed' }] },
} }),
});
const mcpResult = await mcpResponse.json();
assert.equal(mcpResult.result?.structuredContent?.progress?.completed, 1,
`ccweb_update_plan MCP 调用未接受有效计划:${JSON.stringify(mcpResult)}`);
relay = await startRelay(relayPort, serverPort, evidence);
gecko = spawn(GECKODRIVER, ['--port', String(driverPort)], { stdio: ['ignore', 'pipe', 'pipe'] });
const geckoLog = fs.createWriteStream(path.join(root, 'geckodriver.log')); gecko.stdout.pipe(geckoLog); gecko.stderr.pipe(geckoLog);
await waitFor(async () => (await fetch(`http://127.0.0.1:${driverPort}/status`)).ok, 'geckodriver 启动失败');
driver = new FirefoxDriver(driverPort);
evidence.browser = await driver.start();
await driver.navigate(`http://127.0.0.1:${relayPort}/`);
await waitFor(async () => {
const ready = await driver.execute(`return !document.querySelector('#app').hidden && !!document.querySelector('.session-item')`);
if (ready) return true;
await driver.execute(`document.querySelector('#login-password').value=arguments[0];document.querySelector('#login-form').requestSubmit();`, [PASSWORD]);
return false;
}, '登录或会话列表未就绪');
await driver.execute(`document.querySelector('.session-item').click();`);
evidence.stages.loaded = await capture(driver, 'loaded', root, false);
const archiveStart = evidence.protocol.length;
fs.renameSync(fixture.rollout, `${fixture.rollout}.isolated-hidden`);
rolloutHidden = true;
await stopChild(server);
server = launchServer('server-restarted.log');
await waitFor(async () => {
assert.equal(server.exitCode, null, '隔离服务重启后已退出');
return (await fetch(`http://127.0.0.1:${serverPort}/`)).ok;
}, '归档验证服务重启失败');
await driver.refresh();
await waitFor(async () => {
const ready = await driver.execute('return !document.querySelector("#app").hidden && !!document.querySelector(".session-item")');
if (ready) return true;
await driver.execute('document.querySelector("#login-password").value=arguments[0];document.querySelector("#login-form").requestSubmit();', [PASSWORD]);
return false;
}, '归档验证重启后登录或会话列表未就绪');
await driver.execute('document.querySelector(".session-item").click();');
evidence.stages.archivedAfterRestart = await capture(driver, 'archived-after-restart', root, false);
const archivedBeforePaging = await driver.execute(`return {
ids: [...document.querySelectorAll('#messages > .msg')].map((node) => node.dataset.messageKey).filter(Boolean),
indices: [...document.querySelectorAll('#messages > .msg')]
.map((node) => Number(node.dataset.messageIndex))
.filter((value) => Number.isFinite(value)),
notice: document.querySelector('#history-load-more')?.textContent || '',
};`);
await driver.execute(`const box=document.querySelector('#messages'); box.scrollTop=0;`);
await waitFor(async () => {
const state = await driver.execute(`return {
earliest: [...document.querySelectorAll('#messages .msg')].some((node) => node.textContent.includes('早期问题 0')),
canLoad: !!document.querySelector('.history-load-more-button:not([disabled])'),
};`);
if (state.earliest) return true;
if (state.canLoad) {
await driver.execute(`document.querySelector('.history-load-more-button:not([disabled])').click();`);
}
return false;
}, '原始记录移开并重启后,最早消息无法从自身归档读回', 30000);
const archived = await driver.execute(`return {
ids: [...document.querySelectorAll('#messages > .msg')].map((node) => node.dataset.messageKey).filter(Boolean),
indices: [...document.querySelectorAll('#messages > .msg')]
.map((node) => Number(node.dataset.messageIndex))
.filter((value) => Number.isFinite(value)),
notice: document.querySelector('#history-load-more')?.textContent || '',
};`);
const archiveProtocol = evidence.protocol.slice(archiveStart);
const archiveInfo = archiveProtocol.filter((message) => message.type === 'session_info');
const archiveChunks = archiveProtocol.filter((message) => message.type === 'session_history_chunk');
const archiveMessages = archiveProtocol.flatMap((message) => message.messages || []);
const archiveIds = new Set(archiveMessages.map((message) => message.id).filter(Boolean));
const archiveIndices = new Set(archiveMessages
.map((message) => message.index)
.filter((index) => Number.isFinite(index)));
assert(archiveInfo.length > 0 && archiveInfo.every((message) => message.historySource === 'archive'),
'归档刷新后的 session_info 必须全部来自 archive');
assert(archiveInfo.every((message) => message.historyTotal === 148 && message.historyAvailable === true),
'归档响应必须提供完整总数,且 historyAvailable=true');
assert(archiveChunks.length > 0 && archiveChunks.every((message) => message.historySource === 'archive'),
'归档分页必须全部来自 archive');
assert(archiveChunks.every((message) => message.historyTotal === 148 && message.historyAvailable === true),
'归档分页总数或可用状态不一致');
assert.deepEqual(
[...archiveIndices].sort((left, right) => left - right),
Array.from({ length: 148 }, (_, index) => index),
'归档协议消息下标必须连续覆盖 0 到 147',
);
assert.equal(archiveIds.size, 148, `归档协议应覆盖 148 个稳定 ID,实际 ${archiveIds.size}`);
assert.equal(archived.ids.length, 148, `归档消息总数应为 148,实际 ${archived.ids.length}`);
assert.deepEqual(archived.ids, fixture.historyIds, '归档分页必须保留夹具全部消息稳定 ID 及顺序');
assert.deepEqual(
archived.indices.slice().sort((left, right) => left - right),
Array.from({ length: archived.indices.length }, (_, index) => index),
'归档可见消息下标必须从 0 连续覆盖到 147',
);
assert.deepEqual(
archivedBeforePaging.indices.slice().sort((left, right) => left - right),
Array.from({ length: archivedBeforePaging.indices.length }, (_, index) => index + 136),
'归档首屏必须从消息下标 136 开始',
);
assert.equal(new Set(archived.ids).size, 148, '归档必须保留 148 个唯一稳定 ID');
assert(!archived.notice.includes('无法恢复') && !archived.notice.includes('待核实'),
'完整归档读完后不应保留来源不可用或待核实提示');
await driver.execute(`document.querySelector('#messages').scrollTop=0;`);
await waitFor(() => driver.execute(`const node=[...document.querySelectorAll('#messages > .msg')]
.find((message) => message.dataset.messageKey === arguments[0]);
if (!node) return false;
const box=node.getBoundingClientRect(), style=getComputedStyle(node);
return node.textContent.includes('早期问题 0') && box.top >= 0
&& box.bottom <= innerHeight && box.height > 0 && Number(style.opacity) > 0.9;`,
[fixture.historyIds[0]]), '归档最早问题没有实际显示在视口');
await driver.screenshot(path.join(root, 'archived-paged.png'));
evidence.stages.archivedPaged = archived;
evidence.archived = {
beforePaging: archivedBeforePaging,
afterPaging: archived,
archiveInfoCount: archiveInfo.length,
archiveChunkCount: archiveChunks.length,
protocolStableIdCount: archiveIds.size,
protocolIndexCount: archiveIndices.size,
pagedScreenshot: path.join(root, 'archived-paged.png'),
};
await driver.execute(`const input=document.querySelector('#msg-input');input.value='继续核对页面职责与流程边界';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#send-btn').click();`);
evidence.stages.running = await capture(driver, 'running', root);
await driver.refresh();
evidence.stages.refreshed = await capture(driver, 'refreshed', root);
const previousCount = relay.connectionCount();
relay.disconnect();
await waitFor(() => relay.connectionCount() > previousCount, '真实 WebSocket 断线后没有重连');
await waitFor(() => evidence.protocol.some((message) => message.type === 'resume_session_result'), '没有收到恢复会话结果');
evidence.stages.reconnected = await capture(driver, 'reconnected', root);
fs.writeFileSync(fixture.completeFlag, 'complete\n');
await waitFor(() => driver.execute(`return !document.querySelector('#streaming-msg')`), '完成事件后流式气泡未收束');
evidence.stages.completed = await capture(driver, 'completed', root);
await driver.refresh();
evidence.stages.completedRefreshed = await capture(driver, 'completed-refreshed', root);
assert(evidence.protocol.some((message) => message.historySource === 'merged'), '没有验证到服务端真实 native 前缀合并');
evidence.ok = true;
console.log(JSON.stringify({ ok: true, browser: evidence.browser.browserVersion, planSource: PLAN_SOURCE, root, stages: Object.keys(evidence.stages), nativeCount: fixture.nativeCount, snapshotCount: fixture.snapshotCount }, null, 2));
} catch (error) {
evidence.ok = false; evidence.error = error.stack;
if (driver?.sessionId) await driver.screenshot(path.join(root, 'failure.png')).catch(() => {});
throw new Error(`${error.message}\n证据:${root}`, { cause: error });
} finally {
if (driver) await driver.close().catch(() => {});
if (relay) await relay.close();
await stopChild(gecko);
await stopChild(server);
if (rolloutHidden && fs.existsSync(`${fixture.rollout}.isolated-hidden`)) {
fs.renameSync(`${fixture.rollout}.isolated-hidden`, fixture.rollout);
}
fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2));
}
}
main().catch((error) => { console.error(error); process.exitCode = 1; });