Files
cc-web/scripts/history-browser-check.js

298 lines
19 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 SESSION_ID = '11111111-2222-4333-8444-555555555555';
const THREAD_ID = '11111111-2222-4333-8444-666666666666';
const PASSWORD = 'HistoryBrowserFixture!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;
child.kill('SIGTERM');
await Promise.race([new Promise((resolve) => child.once('exit', resolve)), sleep(1000)]);
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
}
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 browserMock = existingMock.replace(timerCode, `
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, 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, isRunning: msg.isRunning,
ids: msg.messages?.map((message) => message.id),
});
} 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}:当前输出重复或丢失`);
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;`);
// 等待浏览器提交滚动与消息淡入,截图必须展示真实可见内容。
await sleep(500);
result.visibleTail = 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 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;
try {
server = 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_CONFIG_DIR: path.join(root, 'config'), CC_WEB_SESSIONS_DIR: fixture.sessions, CC_WEB_LOGS_DIR: path.join(root, 'logs'),
CODEX_PATH: fixture.mock, CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'), CC_WEB_TASK_BOARD_ENABLED: '0',
}, stdio: ['ignore', 'pipe', 'pipe'],
});
const serverLog = fs.createWriteStream(path.join(root, 'server.log')); server.stdout.pipe(serverLog); server.stderr.pipe(serverLog);
await waitFor(async () => (await fetch(`http://127.0.0.1:${serverPort}/`)).ok, '隔离服务启动失败');
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);
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, 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 {
fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2));
if (driver) await driver.close().catch(() => {});
if (relay) await relay.close();
await stopChild(gecko); await stopChild(server);
}
}
main().catch((error) => { console.error(error); process.exitCode = 1; });