242 lines
12 KiB
JavaScript
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 };
|