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

242 lines
12 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', '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: [] };
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) { 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 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);
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));
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 通过:假 OPEN/断 WS 首屏与运行输出、HTTP 分页顺序、迟到响应隔离、切换间隙完成对账;浏览器未通过 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 };