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