'use strict'; // 使用真实浏览器和隔离转发层模拟假 OPEN、断连及迟到响应。 const assert = require('node:assert/strict'); const fs = require('node:fs'); const http = require('node:http'); const path = require('node:path'); const { spawn } = require('node:child_process'); const { WebSocket, WebSocketServer } = require('ws'); 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 CDN_FIXTURE_DIR = process.env.CC_WEB_BROWSER_CDN_DIR || ''; const CDN_FILES = ['marked.min.js', 'highlight.min.js', 'mermaid.min.js', 'atom-one-dark.min.css']; class FirefoxDriver { constructor(port) { this.port = port; this.id = ''; } 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), signal: AbortSignal.timeout(10000), }); 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'] }, } } }); this.id = value.sessionId; await this.request('POST', `/session/${this.id}/window/rect`, { width: 1280, height: 1000 }); return value.capabilities.browserVersion; } execute(script, args = []) { return this.request('POST', `/session/${this.id}/execute/sync`, { script, args }); } navigate(url) { return this.request('POST', `/session/${this.id}/url`, { url }); } async close() { if (this.id) await this.request('DELETE', `/session/${this.id}`); } } async function startRelay(port, targetPort, evidence) { const sockets = new Set(); const state = { dropWs: false, blockUpgrades: false, holdId: '', held: [], bytesPerSecond: 0 }; const relay = http.createServer((request, response) => { if (CDN_FIXTURE_DIR && request.url.startsWith('/fixture-cdn/')) { const name = request.url.slice('/fixture-cdn/'.length); if (!CDN_FILES.includes(name)) { response.writeHead(404); response.end(); return; } response.writeHead(200, { 'Content-Type': name.endsWith('.css') ? 'text/css' : 'application/javascript' }); response.end(fs.readFileSync(path.join(CDN_FIXTURE_DIR, name))); return; } const isQuery = request.url.startsWith('/api/sessions/'); const hold = state.holdId && request.url.startsWith('/api/sessions/' + state.holdId + '?'); if (isQuery) evidence.http.push({ url: request.url, start: Date.now() }); const upstream = http.request({ hostname: '127.0.0.1', port: targetPort, path: request.url, method: request.method, headers: request.headers }, result => { if (CDN_FIXTURE_DIR && result.headers['content-type']?.includes('text/html')) { const chunks = []; result.on('data', chunk => chunks.push(chunk)); result.on('end', () => { const body = Buffer.concat(chunks).toString().replace(/https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/[^"']+/g, url => '/fixture-cdn/' + url.slice(url.lastIndexOf('/') + 1)); const headers = { ...result.headers }; delete headers['content-length']; response.writeHead(result.statusCode, headers); response.end(body); }); return; } if (!hold && isQuery && state.bytesPerSecond) { const chunks = []; result.on('data', chunk => chunks.push(chunk)); result.on('end', () => { const body = Buffer.concat(chunks); const bytesPerTick = Math.max(1, Math.floor(state.bytesPerSecond / 20)); let offset = 0; response.writeHead(result.statusCode, result.headers); const timer = setInterval(() => { const end = Math.min(body.length, offset + bytesPerTick); response.write(body.subarray(offset, end)); offset = end; if (offset === body.length) { clearInterval(timer); response.end(); } }, 50); response.on('close', () => clearInterval(timer)); }); return; } if (!hold) { response.writeHead(result.statusCode, result.headers); result.pipe(response); return; } const chunks = []; result.on('data', chunk => chunks.push(chunk)); result.on('end', () => state.held.push({ response, status: result.statusCode, headers: result.headers, body: Buffer.concat(chunks) })); }); upstream.on('error', () => response.destroy()); request.pipe(upstream); }); const wsServer = new WebSocketServer({ noServer: true }); relay.on('upgrade', (request, socket, head) => { if (state.blockUpgrades) { socket.destroy(); return; } wsServer.handleUpgrade(request, socket, head, client => { sockets.add(client); const upstream = new WebSocket(`ws://127.0.0.1:${targetPort}/ws`); const pending = []; upstream.on('open', () => pending.splice(0).forEach(data => upstream.send(data))); client.on('message', data => { try { evidence.ws.push({ direction: 'client', type: JSON.parse(String(data)).type }); } catch {} if (state.dropWs) return; if (upstream.readyState === WebSocket.OPEN) upstream.send(data); else pending.push(data); }); upstream.on('message', data => { try { const msg = JSON.parse(String(data)); evidence.ws.push({ direction: 'server', type: msg.type, historyChanged: msg.historyChanged }); } catch {} if (!state.dropWs && 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(); }); client.on('error', () => upstream.terminate()); upstream.on('error', () => client.terminate()); }); }); await new Promise(resolve => relay.listen(port, '127.0.0.1', resolve)); return { state, disconnect() { for (const socket of sockets) socket.terminate(); }, release() { state.holdId = ''; for (const item of state.held.splice(0)) { if (item.response.destroyed) continue; item.response.writeHead(item.status, item.headers); item.response.end(item.body); } }, async close() { this.release(); this.disconnect(); wsServer.close(); relay.closeAllConnections(); await new Promise(resolve => relay.close(resolve)); }, }; } async function runBrowser(options) { const { port, fixture, root, query, A, B, PASSWORD, STREAM, waitFor, freePort, stopChild, sleep } = options; const evidence = { http: [], ws: [], stages: {} }; if (CDN_FIXTURE_DIR) for (const name of CDN_FILES) assert(fs.existsSync(path.join(CDN_FIXTURE_DIR, name)), '缺少浏览器静态依赖 ' + name); const relayPort = await freePort(); const driverPort = await freePort(); const relay = await startRelay(relayPort, port, evidence); const gecko = spawn(GECKODRIVER, ['--port', String(driverPort)], { stdio: ['ignore', 'pipe', 'pipe'] }); const log = fs.createWriteStream(path.join(root, 'geckodriver.log')); gecko.stdout.pipe(log); gecko.stderr.pipe(log); const driver = new FirefoxDriver(driverPort); const snapshot = () => driver.execute(`return { title:document.querySelector('#chat-title').textContent, loading:document.body.classList.contains('session-loading-active'), ids:[...document.querySelectorAll('#messages > [data-session-message="true"]')].map(node=>node.dataset.messageId), streaming:document.querySelector('#streaming-msg')?.textContent || '', text:document.querySelector('#messages').textContent, };`); const click = id => driver.execute(`const node=document.querySelector('.session-item[data-id="'+arguments[0]+'"]'); if(!node) throw new Error('找不到会话 '+arguments[0]); node.click();`, [id]); async function loaded(id, count, stream = false) { return waitFor(async () => { const value = await snapshot(); return value.title === id && !value.loading && value.ids.length === count && (!stream || value.streaming.includes(STREAM)) ? value : null; }, '浏览器未显示 ' + id + ' 的 ' + count + ' 条消息'); } try { await waitFor(async () => (await fetch(`http://127.0.0.1:${driverPort}/status`)).ok, 'geckodriver 启动失败'); evidence.browser = await driver.start(); await driver.navigate(`http://127.0.0.1:${relayPort}/`); await waitFor(async () => { const ready = await driver.execute(`return !document.querySelector('#app').hidden && !!document.querySelector('.session-item[data-id="'+arguments[0]+'"]');`, [A]); if (ready) return true; await driver.execute(`document.querySelector('#login-password').value=arguments[0]; document.querySelector('#login-form').requestSubmit();`, [PASSWORD]); return false; }, '浏览器登录未就绪'); relay.state.dropWs = true; let started = Date.now(); await click(A); await loaded(A, 12, true); evidence.stages.fakeOpen = { durationMs: Date.now() - started, count: 12, runtimeVisible: true }; await driver.execute(`window.__switchErrors=[];window.addEventListener('error',e=>__switchErrors.push(e.message)); window.addEventListener('unhandledrejection',e=>__switchErrors.push(String(e.reason)));`); relay.state.bytesPerSecond = 32768; started = Date.now(); await click('http-large-tools'); await loaded('http-large-tools', 12); const compact = await driver.execute(`return { tools:document.querySelectorAll('#messages .tool-call').length, contents:document.querySelectorAll('#messages .tool-call-content').length, bytes:performance.getEntriesByType('resource').filter(x=>x.name.includes('/api/sessions/http-large-tools?')).at(-1)?.decodedBodySize, };`); assert.equal(compact.tools, 100); assert.equal(compact.contents, 0, '折叠工具不能提前构建详情DOM'); assert(Date.now() - started < 8000, '32KB/s 下工具大结果不应卡住首屏'); evidence.stages.largeTools = { ...compact, durationMs: Date.now() - started, bytesPerSecond: 32768 }; relay.state.bytesPerSecond = 0; await driver.execute(`const tool=document.querySelector('#messages details.tool-call'); const group=tool.closest('.tool-group');if(group)group.open=true;tool.querySelector('summary').click();`); await waitFor(() => driver.execute(`return document.querySelector('#messages details.tool-call .tool-call-content')?.textContent.includes('结果终点');`), '完整工具详情未显示'); evidence.stages.toolDetail = { complete: true }; relay.state.holdId = 'http-large-tools'; await driver.execute(`document.querySelectorAll('#messages details.tool-call')[1].querySelector('summary').click();`); await waitFor(() => relay.state.held.length, '工具详情延迟响应未到达'); await click(B); await loaded(B, 12); relay.release(); await sleep(100); await loaded(B, 12); evidence.stages.detailCancellation = { previousDetailCannotOverwriteNewView: true }; assert.deepEqual(await driver.execute('return window.__switchErrors;'), [], '切换及展开不能产生浏览器异常'); await click(B); await loaded(B, 12); for (const count of [36, 58]) { await driver.execute(`document.querySelector('.history-load-more-button').click();`); await loaded(B, count); } const full = await snapshot(); assert.equal(new Set(full.ids).size, 58); assert.deepEqual(full.ids.slice(0, 57), Array.from({ length: 57 }, (_, i) => B + '-' + i)); evidence.stages.pagination = { count: full.ids.length, unique: true, ordered: true }; relay.state.holdId = A; await click(A); await waitFor(() => relay.state.held.length, 'A 的延迟响应未到达转发层'); await click(B); relay.release(); await sleep(150); await loaded(B, 58); evidence.stages.lateResponse = { title: B, count: 58 }; relay.state.holdId = A; started = Date.now(); await click(A); await waitFor(() => relay.state.held.length, '断连前的 HTTP 响应未到达'); relay.state.blockUpgrades = true; relay.disconnect(); await sleep(100); relay.release(); await loaded(A, 12, true); evidence.stages.disconnected = { durationMs: Date.now() - started, runtimeVisible: true, inFlightPreserved: true }; await click(B); await click(A); await loaded(A, 12, true); // 旧 HTTP 快照已读出后任务完成;恢复连接再释放旧响应,必须通过修订对账补齐终态。 await click(B); relay.state.holdId = A; await click(A); await waitFor(() => relay.state.held.length, '完成间隙的 HTTP 快照未到达'); assert.equal(JSON.parse(relay.state.held[0].body).isRunning, true); const runtimeDetailRef = JSON.parse(relay.state.held[0].body).runtime.toolCalls.find(tool => tool.kind === 'command_execution').detailRef; fs.writeFileSync(fixture.completeFlag, 'complete'); await waitFor(async () => { const result = (await query(A)).payload; return !result.isRunning && result.messages.some(message => message.content.includes('Codex App mock handled:')); }, 'mock 任务未保存完成消息'); relay.state.dropWs = false; relay.state.blockUpgrades = false; relay.release(); const completed = await waitFor(async () => { const value = await snapshot(); return value.title === A && !value.loading && !value.streaming && value.text.includes('Codex App mock handled:') ? value : null; }, '读取与订阅之间完成的消息未补齐', 12000); assert(evidence.ws.some(msg => msg.type === 'session_subscribed' && msg.historyChanged)); const archivedTool = await query(A, '?toolDetail=' + encodeURIComponent(JSON.stringify(runtimeDetailRef))); assert.equal(archivedTool.response.status, 200, '运行工具归档后,原详情引用仍须可读取'); assert(archivedTool.payload.tool.result.includes('运行结果终点')); evidence.stages.completionGap = { count: completed.ids.length, streaming: false, finalVisible: true }; await click('http-corrupt-chunk'); await waitFor(async () => { const value = await snapshot(); return value.title === A && !value.loading && value.text.includes('会话历史读取失败'); }, '历史读取失败时页面未结束加载并保留当前会话'); evidence.stages.readFailure = { previousViewPreserved: true, loading: false }; assert(!evidence.ws.some(msg => msg.direction === 'client' && ['load_session', 'load_history_page', 'resume_session'].includes(msg.type))); fs.writeFileSync(path.join(root, 'browser-evidence.json'), JSON.stringify(evidence, null, 2)); console.log('Firefox 通过:32KB/s 大工具会话首屏、折叠懒建、完整详情、假 OPEN/断 WS 运行恢复、分页、迟到隔离与完成交错'); } catch (error) { evidence.failure = error.message; evidence.state = await snapshot().catch(() => null); fs.writeFileSync(path.join(root, 'browser-evidence.json'), JSON.stringify(evidence, null, 2)); throw error; } finally { await driver.close().catch(() => {}); await stopChild(gecko); await relay.close(); log.end(); } } module.exports = { runBrowser };