修复完整会话历史与长气泡交互并更新 CentOS 7 发布包

提交当前工作区全部源码、回归脚本、任务记录及重新构建的 Bun baseline 发布包。发布单文件 MCP 初始化及前端资源一致性验证通过。
This commit is contained in:
shiyue
2026-10-10 23:40:16 +08:00
parent 747771a048
commit 69b6db0f5b
71 changed files with 9536 additions and 503 deletions

View File

@@ -0,0 +1,498 @@
#!/usr/bin/env node
'use strict';
// 真实浏览器与隔离服务验证同一任务的重试气泡;所有会话、日志和门控文件均位于临时目录。
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 crypto = require('node:crypto');
const { spawn } = require('node:child_process');
const { WebSocket, WebSocketServer } = require('ws');
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 SESSION = '12222222-3333-4444-8555-666666666666';
const OTHER = '12222222-3333-4444-8555-777777777777';
const THREAD = '12222222-3333-4444-8555-888888888888';
const PASSWORD = 'StreamingRetryBrowserFixture!234';
const QUESTION = 'streaming browser retry fixture';
const BROWSER_ASSETS = [
{ url: 'https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js', file: 'marked.min.js' },
{ url: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', file: 'highlight.min.js' },
{ url: 'https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js', file: 'mermaid.min.js' },
{ url: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css', file: 'atom-one-dark.min.css' },
];
const PARTS = {
A: '第一段最早正文\n\n' + '甲'.repeat(128000) + '\n\n第一段完整末尾\n',
B: '\n第二段续跑正文\n' + '乙'.repeat(4000) + '\n第二段完整末尾\n',
C: '\n第三段续跑正文\n\n最后回复的第一自然段。\n\n- 第一条完整列表内容\n- 第二条完整列表内容\n\n```js\nconst retained = true;\n```\n\n```html\n<div class="agent-message-divider">代码中的假分界</div>\n```\n\n' + '丙'.repeat(4000) + '\n\n最后回复的第二自然段。\n\n第三段完整末尾\n',
};
const MARKERS = {
A: ['第一段最早正文', '第一段完整末尾'],
B: ['第二段续跑正文', '第二段完整末尾'],
C: ['第三段续跑正文', '第三段完整末尾'],
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function sourceFingerprints() {
return Object.fromEntries(['server.js', 'lib/agent-runtime.js', 'lib/codex-app-runtime.js', 'lib/session-history-store.js',
'public/app.js', 'public/style.css', 'scripts/mock-codex-app-server.js', 'scripts/streaming-retry-browser-check.js'].map((file) => [
file, crypto.createHash('sha256').update(fs.readFileSync(path.join(REPO, file))).digest('hex'),
]));
}
async function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
});
}
async function waitFor(predicate, description, timeout = 8000) {
const start = Date.now();
let lastError;
while (Date.now() - start < timeout) {
try { const value = await predicate(); if (value) return value; } catch (error) { lastError = error; }
await sleep(60);
}
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', pageLoadStrategy: 'eager', 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'], prefs: {
'app.update.auto': false, 'browser.newtabpage.enabled': false, 'browser.startup.page': 0,
'browser.tabs.remote.autostart': false, 'dom.ipc.processCount': 1, 'dom.ipc.processPrelaunch.enabled': false,
'fission.autostart': false, 'gfx.webrender.software': true, 'browser.shell.checkDefaultBrowser': false,
} },
} } });
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 pressKey(selector, key) {
await this.execute('document.querySelector(arguments[0]).focus();', [selector]);
const value = key === 'Enter' ? '\uE007' : ' ';
await this.request('POST', `/session/${this.sessionId}/actions`, { actions: [{
type: 'key', id: 'keyboard', actions: [{ type: 'keyDown', value }, { type: 'keyUp', value }],
}] });
await this.request('DELETE', `/session/${this.sessionId}/actions`);
}
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}`); }
}
class ChromiumDriver {
constructor() { this.sessionId = 'chromium'; }
async start() {
const { chromium } = require(process.env.CC_WEB_BROWSER_PLAYWRIGHT || '/home/hdzx/node_modules/playwright');
this.browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
this.page = await this.browser.newPage({ viewport: { width: 1280, height: 1050 } });
return { browserName: 'chromium', browserVersion: this.browser.version() };
}
execute(script, args = []) {
return this.page.evaluate(({ script, args }) => new Function(script).apply(null, args), { script, args });
}
navigate(url) { return this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 8000 }); }
refresh() { return this.page.reload({ waitUntil: 'domcontentloaded', timeout: 8000 }); }
pressKey(selector, key) { return this.page.locator(selector).press(key); }
screenshot(file) { return this.page.screenshot({ path: file }); }
request(_method, endpoint, body) {
if (endpoint.endsWith('/window/rect')) return this.page.setViewportSize({ width: body.width, height: body.height });
throw new Error('Chromium 夹具不支持 ' + endpoint);
}
async close() { await this.browser?.close(); }
}
function createFixture(root) {
const home = path.join(root, 'fixture-home'), sessions = path.join(root, 'sessions');
for (const folder of [home, sessions, path.join(home, '.codex', 'sessions'), path.join(root, 'config'), path.join(root, 'logs')]) fs.mkdirSync(folder, { recursive: true });
const metadata = { agent: 'codexapp', cwd: root, created: '2026-10-10T01:00:00.000Z', updated: '2026-10-10T01:00:00.000Z', permissionMode: 'yolo' };
fs.writeFileSync(path.join(sessions, SESSION + '.json'), JSON.stringify({ ...metadata, id: SESSION,
title: '重试气泡浏览器夹具', codexAppThreadId: THREAD, messages: [],
}));
fs.writeFileSync(path.join(sessions, OTHER + '.json'), JSON.stringify({ ...metadata, id: OTHER,
title: '切换隔离目标', messages: [{ id: 'other-user', role: 'user', content: '另一个会话的问题' },
{ id: 'other-answer', role: 'assistant', content: '另一个会话的回答' }],
}));
fs.writeFileSync(path.join(root, 'config', 'codex.json'), JSON.stringify({ mode: 'local', retry: { mode: 'limited', intervalSeconds: 4, maxAttempts: 4 } }));
const gates = ['retry-first', 'retry-second', 'complete-turn'].map((name) => path.join(root, name));
const preload = path.join(root, 'isolated-home.cjs');
const baseline = process.env.CC_WEB_STREAMING_BROWSER_BASELINE_SERVER || '';
if (baseline) assert(fs.existsSync(baseline), '指定的基线源码不存在');
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(${JSON.stringify(baseline)} || filename, 'utf8');
const needle = "const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');";
if (!source.includes(needle)) throw new Error('原生历史目录注入点已变更');
module._compile(source.replace(needle, 'const CODEX_SESSIONS_DIR = ' + ${JSON.stringify(JSON.stringify(path.join(home, '.codex', 'sessions')))} + ';'), filename);
};
`);
const mock = path.join(root, 'mock-codex.js');
const originalMock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8');
const needle = " const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80;";
assert(originalMock.includes(needle), 'mock 默认启动分支已变更');
const replacement = `
if (/streaming browser retry fixture/i.test(text)) {
const attempt = (thread.browserRetryAttempt || 0) + 1;
thread.browserRetryAttempt = attempt;
const label = attempt === 1 ? 'A' : attempt === 2 ? 'B' : 'C';
const output = ${JSON.stringify(PARTS)}[label];
for (let offset = 0; offset < output.length; offset += 8192) {
send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'browser-reused-message',delta:output.slice(offset,offset+8192)}});
}
send({method:'item/completed',params:{threadId:thread.id,turnId,item:{id:'browser-reused-message',type:'agentMessage',text:output}}});
const item = {id:'browser-reused-tool',type:'commandExecution',command:'echo browser-tool-'+label,status:'inProgress'};
send({method:'item/started',params:{threadId:thread.id,turnId,item}});
send({method:'item/commandExecution/outputDelta',params:{threadId:thread.id,turnId,itemId:item.id,delta:'browser-tool-'+label+'-result'}});
send({method:'item/completed',params:{threadId:thread.id,turnId,item:{...item,status:'completed',exitCode:0,aggregatedOutput:'browser-tool-'+label+'-result'}}});
thread.timer = setInterval(() => {
if (!require('fs').existsSync(${JSON.stringify(gates)}[Math.min(attempt,3)-1])) return;
clearInterval(thread.timer); thread.timer = null;
if (attempt <= 2) { emitCapacityError(thread,turnId); }
else {
send({method:'turn/completed',params:{threadId:thread.id,turn:{id:turnId,status:'completed',items:[]}}});
thread.activeTurnId = null;
}
},40);
return {turn:{id:turnId,status:'running',items:[]}};
}
`;
fs.writeFileSync(mock, originalMock.replace(needle, replacement + needle)
.replace("path.join(__dirname, 'mock-codex.js')", JSON.stringify(path.join(REPO, 'scripts/mock-codex.js'))), { mode: 0o755 });
return { home, sessions, preload, mock, gates, baseline };
}
async function startRelay(port, targetPort, evidence) {
const assetDir = process.env.CC_WEB_STREAMING_BROWSER_ASSET_DIR || '';
if (assetDir) {
// 使用与 index.html 相同版本的公开库字节,隔离测试无需等待外部 CDN。
evidence.assets = BROWSER_ASSETS.map((asset) => {
const content = fs.readFileSync(path.join(assetDir, asset.file));
assert(content.length > 0, '本地浏览器库不能为空:' + asset.file);
return { ...asset, bytes: content.length, sha256: crypto.createHash('sha256').update(content).digest('hex') };
});
}
const server = http.createServer((request, response) => {
const asset = assetDir && BROWSER_ASSETS.find((item) => request.url === '/__streaming_fixture_assets/' + item.file);
if (asset) {
response.writeHead(200, { 'content-type': asset.file.endsWith('.css') ? 'text/css' : 'text/javascript' });
response.end(fs.readFileSync(path.join(assetDir, asset.file)));
return;
}
const upstream = http.request({ hostname: '127.0.0.1', port: targetPort, path: request.url, method: request.method,
headers: { ...request.headers, ...(assetDir ? { 'accept-encoding': 'identity' } : {}) },
}, (result) => {
if (assetDir && result.headers['content-type']?.includes('text/html')) {
const chunks = [];
result.on('data', (chunk) => chunks.push(chunk));
result.on('end', () => {
let html = Buffer.concat(chunks).toString('utf8');
BROWSER_ASSETS.forEach((item) => { html = html.replaceAll(item.url, '/__streaming_fixture_assets/' + item.file); });
response.writeHead(result.statusCode, { ...result.headers, 'content-length': Buffer.byteLength(html) });
response.end(html);
});
return;
}
response.writeHead(result.statusCode, result.headers); result.pipe(response);
});
upstream.on('error', () => response.destroy()); request.pipe(upstream);
});
const sockets = new Set(), wsServer = new WebSocketServer({ noServer: true });
let connections = 0;
server.on('upgrade', (request, socket, head) => wsServer.handleUpgrade(request, socket, head, (client) => {
connections += 1; sockets.add(client);
const upstream = new WebSocket(`ws://127.0.0.1:${targetPort}/ws`), 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 (['resume_generating', 'done', 'system_message'].includes(msg.type)) evidence.protocol.push({
type: msg.type, sessionId: msg.sessionId, assistantMessageId: msg.assistantMessageId || msg.assistantMessage?.id,
textChars: (msg.text || msg.assistantMessage?.content || '').length, retryPending: msg.retryPending === true,
isRunning: msg.isRunning, message: msg.message, tools: (msg.toolCalls || msg.assistantMessage?.toolCalls || []).length,
});
} 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) => server.listen(port, '127.0.0.1', resolve));
return {
count: () => connections,
disconnect() { for (const socket of sockets) socket.terminate(); },
async close() { for (const socket of sockets) socket.terminate(); wsServer.close(); server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); },
};
}
async function capture(driver, stage, labels, evidence, generating = true) {
const result = await waitFor(async () => {
const state = await driver.execute(`return {
current:document.querySelector('#chat-session-id-btn')?.title || '',
streamCount:document.querySelectorAll('#streaming-msg').length,
bubbles:[...document.querySelectorAll('#messages > .msg.assistant')].map(node => ({
text:node.querySelector('.msg-text')?.textContent || node.querySelector('.msg-bubble')?.textContent || '',
key:node.dataset.messageKey || '',streaming:node.id==='streaming-msg',
tools:[...node.querySelectorAll('.tool-call')].map(tool => tool.textContent),
})),
users:[...document.querySelectorAll('#messages > .msg.user')].map(node => node.textContent),
};`);
const bubble = state.bubbles.find((node) => node.text.includes(MARKERS.A[0]));
if (!bubble) return null;
for (const label of labels) {
for (const marker of MARKERS[label]) if (!bubble.text.includes(marker)) return null;
if (!bubble.tools.some((text) => text.includes('browser-tool-' + label + '-result'))) return null;
}
return { ...state, bubble };
}, `${stage}:气泡前文或完整尾段丢失`);
assert.equal(result.streamCount, generating ? 1 : 0, `${stage}:流式气泡数量错误`);
assert.equal(result.bubbles.length, 1, `${stage}:同一逻辑任务只能显示一个助手气泡`);
assert.equal(result.users.filter((text) => text.includes(QUESTION)).length, 1, `${stage}:用户问题重复或消失`);
let previousIndex = -1;
for (const label of labels) {
for (const marker of MARKERS[label]) {
assert.equal(result.bubble.text.split(marker).length - 1, 1, `${stage}:正文重复:${marker}`);
const index = result.bubble.text.indexOf(marker);
assert(index > previousIndex, `${stage}:正文顺序错乱`); previousIndex = index;
}
const first = result.bubble.text.indexOf(MARKERS[label][0]) + MARKERS[label][0].length;
const last = result.bubble.text.indexOf(MARKERS[label][1]);
assert(last - first >= PARTS[label].length - 30, `${stage}:正文中段被裁剪`);
}
assert.equal(result.bubble.tools.length, labels.length, `${stage}:工具气泡丢失或重复`);
for (const label of labels) assert(result.bubble.tools.some((text) => text.includes('browser-tool-' + label + '-result')), `${stage}:工具结果 ${label} 丢失`);
const compact = {
textChars: result.bubble.text.length, key: result.bubble.key, streamCount: result.streamCount,
toolCount: result.bubble.tools.length, labels, textSha256: crypto.createHash('sha256').update(result.bubble.text).digest('hex'),
};
evidence.stages[stage] = compact;
console.log(JSON.stringify({ stage, ...compact }));
return compact;
}
async function selectSession(driver, sessionId) {
await driver.execute(`const node=document.querySelector('.session-item[data-id="'+arguments[0]+'"]');
if(!node)throw new Error('会话列表未出现隔离夹具');node.click();`, [sessionId]);
}
async function checkControls(driver, stage, labels, expanded = false, mobile = false) {
const controls = await driver.execute(`const msg=document.querySelector('#messages > .msg.assistant');
const toggle=msg?.querySelector('.msg-replies-toggle'),copy=msg?.querySelector('.msg-copy-reply-btn');
const content=msg?.querySelector('.assistant-reply-content'),box=content?.getBoundingClientRect();
return {toggle:!!toggle,copy:!!copy,expanded:toggle?.getAttribute('aria-expanded'),
multiCollapsed:msg?.classList.contains('assistant-history-collapsed'),singleCollapsed:msg?.classList.contains('assistant-single-collapsed'),
textChars:content?.textContent.length || msg?.querySelector('.msg-bubble')?.textContent.length,
contentHeight:box?.height,contentMaxHeight:content?getComputedStyle(content).maxHeight:'',
early:[...msg.querySelectorAll('.assistant-earlier-reply')].map(node=>({text:node.textContent,visible:node.getClientRects().length>0})),
dividerCount:msg.querySelectorAll('.agent-message-divider').length,
fencedDivider:!![...msg.querySelectorAll('pre code')].find(node=>node.textContent.includes('代码中的假分界')),
actionButtons:[...msg.querySelectorAll('.msg-action-row > button')].filter(node=>!node.hidden).map(node=>({
className:node.className,top:node.getBoundingClientRect().top,row:node.parentElement.className})),
copyWidth:copy?.getBoundingClientRect().width,copyHeight:copy?.getBoundingClientRect().height,
toggleHeight:toggle?.getBoundingClientRect().height,
width:innerWidth,scrollWidth:document.documentElement.scrollWidth};`);
assert(controls.toggle && controls.copy, `${stage}:折叠或复制入口丢失`);
assert.equal(controls.expanded, expanded ? 'true' : 'false', `${stage}:折叠 aria 状态错误`);
assert.equal(controls.dividerCount, labels.length - 1, `${stage}:代码围栏内的 HTML 不得生成原生分界`);
if (labels.includes('C')) assert(controls.fencedDivider, `${stage}:代码中的原始 HTML 应完整显示`);
const actionClasses = controls.actionButtons.map((button) => button.className);
assert(actionClasses.includes('msg-copy-reply-btn') && actionClasses.includes('msg-last-section-btn'), `${stage}:复制和定位控件应在同一工具行`);
if (stage.startsWith('completed') || mobile) assert(actionClasses.includes('msg-branch-btn'), `${stage}:完成气泡的分支控件应保留`);
assert(controls.actionButtons.every((button) => Math.abs(button.top - controls.actionButtons[0].top) < 1), `${stage}:复制、定位和分支应同排`);
if (labels.length === 1 && !expanded) {
assert(controls.singleCollapsed, `${stage}:无 divider 的长单回复应有受控折叠`);
assert(controls.contentHeight <= 421 && controls.textChars > 128000, `${stage}:折叠不得删正文`);
} else if (labels.length > 1) {
assert.equal(controls.multiCollapsed, !expanded, `${stage}:多原生回复折叠状态错误`);
assert(controls.early.some((node) => node.text.includes(MARKERS.A[0])), `${stage}:第一段仍须存在于 DOM`);
assert(controls.early.every((node) => node.visible === expanded), `${stage}:早期正文可见状态错误`);
}
if (mobile) {
assert(controls.copyWidth >= 44 && controls.copyHeight >= 44 && controls.toggleHeight >= 44, `${stage}:手机触控面积不足`);
assert(controls.scrollWidth <= controls.width + 1, `${stage}:控件导致手机水平溢出`);
}
await driver.execute(`window.__browserCopiedText='';
Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async(value)=>{window.__browserCopiedText=value;}}});
document.execCommand=(name)=>{if(name==='copy'){window.__browserCopiedText=document.activeElement?.value||'';return true;}return false;};
document.querySelector('#messages > .msg.assistant .msg-copy-reply-btn').click();`);
const copied = await waitFor(() => driver.execute('return window.__browserCopiedText'), `${stage}:复制没有写入剪贴板`);
assert.equal(copied, PARTS[labels[labels.length - 1]].trim(), `${stage}:复制应包含完整最新原生回复 Markdown`);
assert(!copied.includes('browser-tool-'), `${stage}:复制混入工具结果`);
return { ...controls, early: controls.early.map((node) => ({ textChars: node.text.length, visible: node.visible })), copiedChars: copied.length };
}
async function toggleControls(driver, key) {
if (key) await driver.pressKey('#messages > .msg.assistant .msg-replies-toggle', key);
else await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-replies-toggle').click();`);
}
async function main() {
const chromium = process.env.CC_WEB_STREAMING_BROWSER_ENGINE === 'chromium';
if (!chromium) assert(fs.existsSync(FIREFOX) && fs.existsSync(GECKODRIVER), '需要本机 Firefox 与 geckodriver');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-streaming-retry-browser-'));
const fixture = createFixture(root);
const evidence = { root, fixture, protocol: [], stages: {}, sourceFingerprints: sourceFingerprints(),
kind: '真实 server.js / 浏览器 / 门控 A-B-C / 隔离存储' };
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_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',
CC_WEB_SESSION_MESSAGE_CONTENT_MAX_CHARS: '4096',
}, 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, '隔离服务启动失败');
console.log('隔离服务就绪:' + root);
relay = await startRelay(relayPort, serverPort, evidence);
if (!chromium) {
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 = chromium ? new ChromiumDriver() : new FirefoxDriver(driverPort);
console.log('开始启动浏览器:' + (chromium ? 'Chromium' : 'Firefox'));
evidence.browser = await driver.start();
console.log('浏览器会话已就绪');
await driver.navigate(`http://127.0.0.1:${relayPort}/`);
await waitFor(() => driver.execute(`return document.querySelector('#login-password')?.getClientRects().length > 0`), '登录表单未就绪');
// 只提交一次,避免认证刷新尚未结束时再次提交造成连续导航。
await driver.execute(`document.querySelector('#login-password').value=arguments[0];document.querySelector('#login-form').requestSubmit();`, [PASSWORD]);
await waitFor(() => driver.execute(`return !document.querySelector('#app').hidden && !!document.querySelector('.session-item')`), '登录或会话列表未就绪');
await selectSession(driver, SESSION);
await waitFor(() => driver.execute(`return document.querySelector('#chat-session-id-btn')?.title.includes(arguments[0])`, [SESSION]), '目标会话未加载');
await driver.execute(`const input=document.querySelector('#msg-input');input.value=arguments[0];input.dispatchEvent(new Event('input',{bubbles:true}));`, [QUESTION]);
await waitFor(() => driver.execute(`return !document.querySelector('#send-btn').disabled`), '初次请求发送控件未就绪');
await driver.execute(`document.querySelector('#send-btn').click();`);
const first = await capture(driver, 'initial-A', ['A'], evidence);
evidence.controls = { initialSingle: await checkControls(driver, 'initial-A', ['A']) };
await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-last-section-btn').click();`);
evidence.singleReplyLocate = await waitFor(() => driver.execute(`
const msg=document.querySelector('#messages > .msg.assistant');
const last=[...msg.querySelectorAll('p')].find(node=>node.textContent.trim()===arguments[0]);
const rect=last?.getBoundingClientRect(),box=document.querySelector('#messages').getBoundingClientRect();
if(!rect || msg.querySelector('.msg-replies-toggle').getAttribute('aria-expanded')!=='true')return null;
return rect.top>=box.top&&rect.bottom<=box.bottom?{visible:true,expanded:true,top:rect.top,bottom:rect.bottom}:null;`, [MARKERS.A[1]]), '单条长回复的定位必须先展开并显示裁剪区末段');
await toggleControls(driver);
await toggleControls(driver, 'Enter');
evidence.controls.initialSingleExpanded = await checkControls(driver, 'initial-A-expanded', ['A'], true);
evidence.controls.initialSingleExpanded.keyboard = 'Enter';
await toggleControls(driver);
await selectSession(driver, OTHER);
await waitFor(() => driver.execute(`return document.querySelector('#messages').textContent.includes('另一个会话的回答')`), '隔离会话未加载');
assert.equal(await driver.execute(`return document.querySelector('#messages').textContent.includes('第一段最早正文')`), false, '长任务正文串入其他会话');
assert.equal(await driver.execute(`return !!document.querySelector('.msg-replies-toggle')`), false, '短历史回复不应显示折叠入口');
await selectSession(driver, SESSION);
await capture(driver, 'switched-back-A', ['A'], evidence);
fs.writeFileSync(fixture.gates[0], 'retry\n');
await waitFor(() => evidence.protocol.some((msg) => msg.type === 'resume_generating' && msg.retryPending), '等待重试未下发完整运行态');
await driver.refresh();
await capture(driver, 'retry-wait-refreshed-A', ['A'], evidence);
const beforeReconnect = relay.count(); relay.disconnect();
await waitFor(() => relay.count() > beforeReconnect, '等待重试期间 WebSocket 未重连');
await capture(driver, 'retry-wait-reconnected-A', ['A'], evidence);
const second = await capture(driver, 'continued-AB', ['A', 'B'], evidence);
assert.equal(second.key, first.key, '重试不能更换逻辑助手消息 ID');
evidence.controls.multiple = await checkControls(driver, 'continued-AB', ['A', 'B']);
await toggleControls(driver, 'Space');
evidence.controls.multipleExpanded = await checkControls(driver, 'continued-AB-expanded', ['A', 'B'], true);
evidence.controls.multipleExpanded.keyboard = 'Space';
await toggleControls(driver);
fs.writeFileSync(fixture.gates[1], 'retry\n');
const third = await capture(driver, 'continued-ABC', ['A', 'B', 'C'], evidence);
assert.equal(third.key, first.key);
fs.writeFileSync(fixture.gates[2], 'done\n');
await waitFor(() => driver.execute(`return !document.querySelector('#streaming-msg')`), '完成后没有收束流式气泡');
await capture(driver, 'completed-ABC', ['A', 'B', 'C'], evidence, false);
await driver.refresh();
const completed = await capture(driver, 'completed-refreshed-ABC', ['A', 'B', 'C'], evidence, false);
assert.equal(completed.key, first.key);
evidence.controls.completed = await checkControls(driver, 'completed-refreshed-ABC', ['A', 'B', 'C']);
await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-replies-toggle').scrollIntoView({block:'start'});`);
await driver.screenshot(path.join(root, 'desktop-collapsed-top.png'));
await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`);
await driver.screenshot(path.join(root, 'desktop-latest-reply.png'));
await toggleControls(driver);
evidence.controls.completedExpanded = await checkControls(driver, 'completed-ABC-expanded', ['A', 'B', 'C'], true);
await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=0;`);
await waitFor(() => driver.execute(`const node=[...document.querySelectorAll('#messages > .msg.assistant .msg-bubble')].find(node=>node.textContent.includes('第一段最早正文'));
if(!node)return false;const rect=node.getBoundingClientRect();return rect.top>=0&&rect.top<innerHeight;`), '最早正文没有实际显示在视口');
await driver.screenshot(path.join(root, 'completed-earliest-text.png'));
await toggleControls(driver);
await driver.request('POST', `/session/${driver.sessionId}/window/rect`, { width: 390, height: 844 });
evidence.controls.mobile = await checkControls(driver, 'mobile-ABC', ['A', 'B', 'C'], false, true);
for (const theme of ['editorial', 'nocturne']) {
await driver.execute(`document.documentElement.dataset.theme=arguments[0];`, [theme]);
await checkControls(driver, 'mobile-' + theme, ['A', 'B', 'C'], false, true);
}
await driver.execute(`document.querySelector('#messages > .msg.assistant .msg-replies-toggle').scrollIntoView({block:'start'});`);
await driver.screenshot(path.join(root, 'mobile-collapsed-top.png'));
await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`);
await driver.screenshot(path.join(root, 'mobile-latest-reply.png'));
await driver.execute(`const input=document.querySelector('#msg-input');input.value='下一用户轮次';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#send-btn').click();`);
await waitFor(() => driver.execute(`return [...document.querySelectorAll('#messages > .msg.assistant')].some(node=>node.textContent.includes('下一用户轮次'))`), '下一用户轮次未完成');
const next = await driver.execute(`return [...document.querySelectorAll('#messages > .msg.assistant')].map(node=>node.textContent)`);
assert.equal(next.length, 2, '新问题应有独立助手气泡');
assert(!next[1].includes(MARKERS.A[0]), '新用户轮次错误继承上轮正文');
evidence.nextUserTurn = { assistantBubbles: next.length, preservedPrevious: next[0].includes(MARKERS.A[0]) };
evidence.ok = true;
console.log(JSON.stringify({ ok: true, root, browser: evidence.browser.browserVersion, stages: Object.keys(evidence.stages) }));
} 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);
evidence.sourceFingerprintsAfter = sourceFingerprints();
evidence.sourcesChangedDuringRun = Object.keys(evidence.sourceFingerprints)
.filter((file) => evidence.sourceFingerprints[file] !== evidence.sourceFingerprintsAfter[file]);
fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2));
}
}
main().catch((error) => { console.error(error); process.exitCode = 1; });