293 lines
15 KiB
JavaScript
293 lines
15 KiB
JavaScript
'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 };
|