#!/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
\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