298 lines
19 KiB
JavaScript
298 lines
19 KiB
JavaScript
#!/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; });
|