Files
cc-web/scripts/session-http-load-browser.js

293 lines
15 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.

'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 };