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

499 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env node
'use strict';
// 真实浏览器与隔离服务验证同一任务的重试气泡;所有会话、日志和门控文件均位于临时目录。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const 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; });