改用 HTTP 加载会话历史并更新 CentOS 7 发布包
This commit is contained in:
@@ -113,6 +113,7 @@ function createHarness() {
|
||||
let collabAgentIdsByToolUseId = new Map();
|
||||
let closedCollabAgentIdsByToolUseId = new Map();
|
||||
let currentSessionId = 'running-session';
|
||||
const sessionCompletionVersions = new Map();
|
||||
let currentSessionMessageCount = 0;
|
||||
let isGenerating = false;
|
||||
let generatingSessionId = null;
|
||||
@@ -224,6 +225,7 @@ function createPaginationHarness() {
|
||||
let activeHistoryPageRequest = null;
|
||||
let activeOutlineHistoryRequest = null;
|
||||
let activeOutlineTargetRequest = null;
|
||||
const currentSessionSubscriptionRequestId = '';
|
||||
let historyPageRequestSeq = 0;
|
||||
let outlineHistoryRequestSeq = 0;
|
||||
const activeSessionLoad = null;
|
||||
@@ -238,6 +240,7 @@ function createPaginationHarness() {
|
||||
const requests = [];
|
||||
const document = { createElement(tagName) { return new Element(tagName); } };
|
||||
function send(request) { requests.push(request); }
|
||||
function fetchHistoryPage(request, before) { request.before = before; requests.push(request); }
|
||||
function shortMessagePreview(value, length) { return String(value).slice(0, length); }
|
||||
function escapeHtml(value) { return String(value); }
|
||||
function isBlockingSessionLoad() { return false; }
|
||||
@@ -381,7 +384,7 @@ for (const outline of [false, true]) {
|
||||
api.connect();
|
||||
api.select('其他会话');
|
||||
requestPage();
|
||||
assert.equal(api.requests.length, 0, '保留认证、连接状态和会话隔离门禁');
|
||||
assert.equal(api.requests.length, 1, 'HTTP 分页不依赖 WebSocket,重复请求及其他会话仍被隔离');
|
||||
api.select('running-session');
|
||||
requestPage();
|
||||
assert.equal(api.requests.length, 1);
|
||||
|
||||
@@ -3793,12 +3793,13 @@ function assertSessionSwitchResilienceContract() {
|
||||
'Frontend should actively detect an OPEN-but-stale WebSocket before session load timeout'
|
||||
);
|
||||
assert(frontendSource.includes('sessionLoadRequestTimer'), 'Frontend should track the session load request timeout timer');
|
||||
assert(frontendSource.includes('function clearPendingSessionSwitchRequest'), 'Frontend should be able to cancel stale pending session switch requests');
|
||||
assert(frontendSource.includes('function scheduleSessionLoadRequestTimeout'), 'Frontend should schedule cancellation for stuck load_session requests');
|
||||
assert(frontendSource.includes('sessionLoadAbortController?.abort()'), 'Frontend should cancel superseded HTTP session queries');
|
||||
assert(frontendSource.includes('function scheduleSessionLoadRequestTimeout'), 'Frontend should bound the duration of session queries');
|
||||
assert(frontendSource.includes('pendingSessionResumeRequest'), 'Frontend should track lightweight running-session resume requests');
|
||||
assert(frontendSource.includes('SESSION_RESUME_FALLBACK_MS'), 'Frontend should keep a compatibility fallback for servers without resume_session');
|
||||
assert(frontendSource.includes('function requestSessionResume'), 'Frontend should request running-session resume without full history reload');
|
||||
assert(frontendSource.includes("type: 'resume_session'"), 'Frontend should use resume_session for reconnecting running conversations');
|
||||
assert(frontendSource.includes("type: 'subscribe_session'"), 'Frontend should subscribe to real-time events without loading history through WebSocket');
|
||||
assert(!frontendSource.includes("type: 'load_session'") && !frontendSource.includes("type: 'load_history_page'"), 'Browser history reads must use HTTP');
|
||||
assert(frontendSource.includes("case 'session_subscribed':"), 'Frontend should handle independent subscription results');
|
||||
assert(frontendSource.includes("case 'resume_session_result':"), 'Frontend should handle lightweight resume results');
|
||||
assert(
|
||||
/case 'resume_session_result':[\s\S]*?if \(!msg\.isRunning && currentSessionId && msg\.sessionId === currentSessionId\) \{[\s\S]*?finishGenerating\(msg\.sessionId \|\| currentSessionId\);[\s\S]*?\}[\s\S]*?break;/.test(frontendSource),
|
||||
@@ -3812,7 +3813,7 @@ function assertSessionSwitchResilienceContract() {
|
||||
/preserveStreaming[\s\S]*?reconcileRenderedSessionMessages\(snapshot\.messages/.test(frontendSource),
|
||||
'Frontend running-session snapshots should reconcile persisted bubbles instead of skipping history'
|
||||
);
|
||||
assert(frontendSource.includes('recoverCurrent: true'), 'Frontend fallback load_session should preserve the current running view');
|
||||
assert(frontendSource.includes('recoverCurrent: true'), 'HTTP reconciliation should preserve the current running view');
|
||||
const visibilityStart = frontendSource.indexOf("document.addEventListener('visibilitychange'");
|
||||
const visibilityEnd = visibilityStart >= 0 ? frontendSource.indexOf("if (!authToken)", visibilityStart) : -1;
|
||||
const visibilitySource = visibilityStart >= 0 && visibilityEnd > visibilityStart
|
||||
@@ -3823,12 +3824,12 @@ function assertSessionSwitchResilienceContract() {
|
||||
assert(!visibilitySource.includes("type: 'load_session'"), 'Visibility restore must not force load_session and rerender the current conversation');
|
||||
assert(!visibilitySource.includes('beginSessionSwitch('), 'Visibility restore must not force a session switch and scroll to bottom');
|
||||
assert(
|
||||
/else if \(currentSessionId && \(isGenerating \|\| currentSessionRunning\)[\s\S]*?pendingSessionResumeRequest\s*=\s*\{/.test(frontendSource),
|
||||
'Frontend should queue a lightweight resume request for running conversations when WS closes'
|
||||
/if \(currentSessionId && !isPageUnloading\)[\s\S]*?pendingSessionResumeRequest\s*=\s*\{/.test(frontendSource),
|
||||
'Frontend should queue a real-time subscription when WS closes'
|
||||
);
|
||||
assert(
|
||||
/const flushedSessionResume = flushedSessionSwitch \? false : flushPendingSessionResume\(\);[\s\S]*?requestSessionResume\(currentSessionId/.test(frontendSource),
|
||||
'Frontend should resume the current running session after auth without forcing load_session'
|
||||
/const flushedSessionResume = sessionLoadAbortController \? false : flushPendingSessionResume\(\);[\s\S]*?requestSessionResume\(currentSessionId/.test(frontendSource),
|
||||
'Frontend should subscribe to the current view after auth without interrupting an HTTP query'
|
||||
);
|
||||
assert(
|
||||
frontendSource.includes("send({ type: 'abort', sessionId: currentSessionId })"),
|
||||
@@ -3851,12 +3852,12 @@ function assertSessionSwitchResilienceContract() {
|
||||
'Frontend should ignore history chunks from recovery fallback to avoid duplicating/redrawing bubbles'
|
||||
);
|
||||
assert(
|
||||
/function flushPendingSessionSwitch\(\)[\s\S]*?return false[\s\S]*?return true/.test(frontendSource),
|
||||
'Frontend flushPendingSessionSwitch should report whether it sent a load_session request'
|
||||
/fetch\(`\/api\/sessions\//.test(frontendSource),
|
||||
'Frontend should load sessions and history pages through the authenticated HTTP endpoint'
|
||||
);
|
||||
assert(
|
||||
/pendingSessionSwitchRequest\s*=\s*\{[\s\S]*?blocking:\s*false[\s\S]*?requestId:\s*activeSessionLoad\.requestId/.test(frontendSource),
|
||||
'Frontend should retry load_session after WS close without re-blocking the UI'
|
||||
!extractFunctionSource(frontendSource, 'connect').slice(extractFunctionSource(frontendSource, 'connect').indexOf('socket.onclose')).includes('clearSessionLoading()'),
|
||||
'WebSocket close must not cancel an independent HTTP history query'
|
||||
);
|
||||
assert(
|
||||
frontendSource.includes('!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession'),
|
||||
@@ -7723,6 +7724,15 @@ async function main() {
|
||||
console.log('Session switch race regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'session-http-load') {
|
||||
assertSessionSwitchResilienceContract();
|
||||
const result = spawnSync(process.execPath, [path.join(REPO_DIR, 'scripts', 'session-http-load-regression.js')], {
|
||||
encoding: 'utf8', timeout: 60000,
|
||||
});
|
||||
assert(result.status === 0, result.stderr || result.stdout || result.error?.message || 'HTTP 会话回归失败');
|
||||
console.log(result.stdout.trim());
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'history-recall') {
|
||||
assertHistoryRecallContract();
|
||||
console.log('History recall regression checks passed.');
|
||||
|
||||
241
scripts/session-http-load-browser.js
Normal file
241
scripts/session-http-load-browser.js
Normal file
@@ -0,0 +1,241 @@
|
||||
'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 };
|
||||
169
scripts/session-http-load-frontend-unit.js
Normal file
169
scripts/session-http-load-frontend-unit.js
Normal file
@@ -0,0 +1,169 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const match = new RegExp(`(?:async )?function ${name}\\(`).exec(source);
|
||||
assert(match, '缺少生产函数 ' + name);
|
||||
let parentheses = 0;
|
||||
let signatureEnd;
|
||||
for (let index = source.indexOf('(', match.index); index < source.length; index++) {
|
||||
if (source[index] === '(') parentheses++;
|
||||
if (source[index] === ')' && --parentheses === 0) { signatureEnd = index; break; }
|
||||
}
|
||||
let depth = 0;
|
||||
for (let index = source.indexOf('{', signatureEnd); index < source.length; index++) {
|
||||
if (source[index] === '{') depth++;
|
||||
if (source[index] === '}' && --depth === 0) return source.slice(match.index, index + 1);
|
||||
}
|
||||
throw new Error('函数边界异常 ' + name);
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const network = [];
|
||||
const api = new Function('network', 'AbortController', 'URLSearchParams', `
|
||||
let currentSessionId = 'original';
|
||||
let currentSessionViewRequestId = 'view-original';
|
||||
let currentSessionHistoryRevision = 'r0';
|
||||
let currentSessionSubscriptionRequestId = '';
|
||||
let sessionLoadAbortController = null;
|
||||
let activeSessionLoad = null;
|
||||
let activeHistoryPageRequest = null;
|
||||
let activeOutlineHistoryRequest = null;
|
||||
let activeOutlineTargetRequest = null;
|
||||
let currentHistoryState = {};
|
||||
let currentOutlineHistoryState = {};
|
||||
let pendingSessionResumeRequest = null;
|
||||
let ws = null;
|
||||
let wsAuthenticated = false;
|
||||
const authToken = 'test-http-token';
|
||||
const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45000;
|
||||
const sessionCompletionVersions = new Map();
|
||||
const events = [], errors = [], sent = [], timers = new Map();
|
||||
let sequence = 0;
|
||||
function setTimeout(callback, ms) { const id = ++sequence; timers.set(id, {callback, ms}); return id; }
|
||||
function clearTimeout(id) { timers.delete(id); }
|
||||
function createSessionSwitchRequestId(id) { return 'query-' + ++sequence + '-' + id; }
|
||||
function clearSessionLoadRequestTimer() {}
|
||||
function renderHistoryLoadControl() {}
|
||||
function setSessionLoading(id, options) {
|
||||
sessionLoadAbortController?.abort(); sessionLoadAbortController = null;
|
||||
activeSessionLoad = id ? { sessionId:id, ...options } : null;
|
||||
}
|
||||
function clearSessionLoading() { setSessionLoading(null, {}); }
|
||||
function handleServerMessage(payload) {
|
||||
if (payload.throwRender) throw new Error('夹具渲染失败');
|
||||
events.push(payload);
|
||||
if (payload.type === 'session_info') { currentSessionId = payload.sessionId; activeSessionLoad = null; }
|
||||
if (payload.type === 'session_history_chunk') activeHistoryPageRequest = null;
|
||||
}
|
||||
function appendError(message) { errors.push(message); }
|
||||
function showToast(message) { errors.push(message); }
|
||||
function finishGenerating(id) { events.push({ type:'finished', sessionId:id }); }
|
||||
function requestOutlineMessageTarget(index) { events.push({ type:'target', index }); }
|
||||
function connect() {}
|
||||
function fetch(url, options) { return new Promise((resolve, reject) => network.push({url,options,resolve,reject})); }
|
||||
${['requestSessionLoad','fetchSessionQuery','fetchHistoryPage','requestSessionResume','flushPendingSessionResume'].map(extractFunction).join('\n')}
|
||||
return {
|
||||
requestSessionLoad, fetchSessionQuery, events, errors, sent, timers,
|
||||
state: () => ({ currentSessionId, currentSessionViewRequestId, activeSessionLoad, sessionLoadAbortController }),
|
||||
complete: id => sessionCompletionVersions.set(id, (sessionCompletionVersions.get(id) || 0) + 1),
|
||||
page: (id, requestId) => { activeHistoryPageRequest = {sessionId:id, requestId};
|
||||
fetchHistoryPage(activeHistoryPageRequest, 24); return activeHistoryPageRequest; },
|
||||
socket: open => { wsAuthenticated = open; ws = open ? {readyState:1,send:data=>sent.push(JSON.parse(data))} : null; },
|
||||
};
|
||||
`)(network, AbortController, URLSearchParams);
|
||||
return { ...api, network };
|
||||
}
|
||||
function respond(api, index, overrides = {}, status = 200) {
|
||||
const request = api.network[index];
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const isPage = url.pathname.endsWith('/history');
|
||||
const payload = { type: isPage ? 'session_history_chunk' : 'session_info',
|
||||
requestId: url.searchParams.get('requestId'), sessionId: url.pathname.split('/')[3],
|
||||
messages: [], historyRevision: 'r1', ...overrides };
|
||||
request.resolve({ ok: status === 200, json: async () => payload });
|
||||
}
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
async function runFrontendUnit() {
|
||||
{
|
||||
const api = createHarness();
|
||||
api.requestSessionLoad('A');
|
||||
assert.equal(api.network.length, 1, '没有 WebSocket 也必须立即查询 HTTP');
|
||||
assert.equal(api.network[0].options.headers.Authorization, 'Bearer test-http-token');
|
||||
api.requestSessionLoad('B', { requestId: 'new-B' });
|
||||
assert(api.network[0].options.signal.aborted, '连续切换必须取消旧读取');
|
||||
respond(api, 1);
|
||||
await flush();
|
||||
respond(api, 0, { runtime: { type: 'resume_generating', sessionId: 'A', text: '迟到输出' } });
|
||||
await flush();
|
||||
assert.equal(api.state().currentSessionId, 'B');
|
||||
assert.deepEqual(api.events.map(event => event.type), ['session_info'], '忽略取消后仍然抵达的旧响应和旧运行输出');
|
||||
assert.equal(api.sent.length, 0);
|
||||
}
|
||||
{
|
||||
const api = createHarness();
|
||||
api.requestSessionLoad('A', { requestId: 'old-A' });
|
||||
api.requestSessionLoad('A', { requestId: 'new-A' });
|
||||
respond(api, 0); await flush();
|
||||
assert.equal(api.events.length, 0, '同一会话也必须隔离请求代次');
|
||||
respond(api, 1, { runtime: { type: 'resume_generating', sessionId: 'A', text: '完整输出' } });
|
||||
await flush();
|
||||
assert.equal(api.events[1].text, '完整输出');
|
||||
assert(!api.events.some(event => event.type === 'target'), '未指定定位不能被 null 转换成第0条');
|
||||
const page = api.page('A', 'older');
|
||||
api.requestSessionLoad('B', { requestId: 'B' });
|
||||
assert(page.controller.signal.aborted);
|
||||
respond(api, 2); await flush();
|
||||
assert(!api.events.some(event => event.type === 'session_history_chunk'), '新视图必须拒绝旧分页');
|
||||
respond(api, 3); await flush();
|
||||
}
|
||||
{
|
||||
const api = createHarness();
|
||||
api.socket(true);
|
||||
api.requestSessionLoad('A', { requestId: 'before-done' });
|
||||
api.complete('A');
|
||||
respond(api, 0, { isRunning: true }); await flush();
|
||||
assert.equal(api.events.length, 0, '已收到完成事件后不能应用较早的运行快照');
|
||||
assert.equal(api.network.length, 2, '完成事件与 HTTP 交错时应重新读取持久化终态');
|
||||
respond(api, 1, { isRunning: false }); await flush();
|
||||
assert.equal(api.events[0].isRunning, false);
|
||||
assert.deepEqual(api.sent.map(msg => msg.type), ['subscribe_session']);
|
||||
}
|
||||
{
|
||||
const api = createHarness();
|
||||
for (const [overrides, message] of [
|
||||
[{ requestId: 'unmatched' }, /响应与请求不匹配/],
|
||||
[{ type: 'invalid_payload' }, /响应格式无效/],
|
||||
[{ throwRender: true }, /夹具渲染失败/],
|
||||
]) {
|
||||
const index = api.network.length;
|
||||
api.requestSessionLoad('invalid');
|
||||
respond(api, index, overrides); await flush();
|
||||
assert.match(api.errors.at(-1), message);
|
||||
assert.equal(api.state().activeSessionLoad, null, '响应或渲染异常不能留下永久加载');
|
||||
}
|
||||
}
|
||||
{
|
||||
const api = createHarness();
|
||||
api.requestSessionLoad('broken');
|
||||
respond(api, 0, { message: '夹具历史读取失败' }, 500); await flush();
|
||||
assert.equal(api.errors[0], '夹具历史读取失败');
|
||||
assert.equal(api.state().activeSessionLoad, null);
|
||||
assert.equal(api.state().currentSessionId, 'original', '失败不能清空当前视图');
|
||||
api.requestSessionLoad('timeout');
|
||||
for (const timer of api.timers.values()) timer.callback();
|
||||
assert(api.network[1].options.signal.aborted);
|
||||
api.network[1].reject(new Error('aborted')); await flush();
|
||||
assert.match(api.errors.at(-1), /HTTP 请求超时/);
|
||||
assert.equal(api.timers.size, 0, '成功、失败和超时均须清理请求计时器');
|
||||
}
|
||||
console.log('HTTP 前端单测通过:无 WS 读取、取消、跨会话/同会话迟到隔离、分页取消、运行快照、完成交错、500及超时恢复');
|
||||
}
|
||||
|
||||
module.exports = { runFrontendUnit };
|
||||
if (require.main === module) runFrontendUnit().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
219
scripts/session-http-load-regression.js
Normal file
219
scripts/session-http-load-regression.js
Normal file
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// 真实服务隔离回归,不读取或修改生产会话。
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { WebSocket } = require('ws');
|
||||
const { createSessionHistoryStore } = require('../lib/session-history-store');
|
||||
const REPO = path.resolve(__dirname, '..');
|
||||
const PASSWORD = 'SessionHttpFixture!234';
|
||||
const STREAM = 'HTTP 夹具正在运行的完整输出';
|
||||
const A = 'http-switch-a';
|
||||
const B = 'http-switch-b';
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function waitFor(fn, label, timeout = 10000) {
|
||||
const started = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - started < timeout) {
|
||||
try { const result = await fn(); if (result) return result; } catch (error) { lastError = error; }
|
||||
await sleep(40);
|
||||
}
|
||||
throw new Error(label + (lastError ? ':' + lastError.message : ''));
|
||||
}
|
||||
async function freePort() {
|
||||
const listener = net.createServer();
|
||||
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
|
||||
const port = listener.address().port;
|
||||
await new Promise(resolve => listener.close(resolve));
|
||||
return port;
|
||||
}
|
||||
async function stopChild(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode) return;
|
||||
const exited = new Promise(resolve => child.once('exit', resolve));
|
||||
child.kill('SIGTERM');
|
||||
await Promise.race([exited, sleep(500)]);
|
||||
if (child.exitCode === null && !child.signalCode) { child.kill('SIGKILL'); await exited; }
|
||||
}
|
||||
async function connect(port) {
|
||||
const ws = new WebSocket('ws://127.0.0.1:' + port + '/ws');
|
||||
const messages = [];
|
||||
ws.on('message', data => messages.push(JSON.parse(String(data))));
|
||||
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
|
||||
ws.send(JSON.stringify({ type: 'auth', password: PASSWORD }));
|
||||
const auth = await waitFor(() => messages.find(msg => msg.type === 'auth_result'), '隔离登录超时');
|
||||
assert(auth.success);
|
||||
return { ws, messages, token: auth.token };
|
||||
}
|
||||
function createFixture(root) {
|
||||
const sessionsDir = path.join(root, 'sessions');
|
||||
const homeDir = path.join(root, 'home');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
const store = createSessionHistoryStore({ sessionsDir, chunkMaxMessages: 8 });
|
||||
function seed(id, count = 57) {
|
||||
const now = new Date().toISOString();
|
||||
const session = { id, agent: 'codexapp', title: id, cwd: homeDir, created: now, updated: now,
|
||||
permissionMode: 'yolo', hasUnread: false,
|
||||
messages: Array.from({ length: count }, (_, index) => ({ id: id + '-' + index,
|
||||
role: index % 2 ? 'assistant' : 'user', content: id + ' 保存消息 ' + index, timestamp: now })) };
|
||||
store.save(session);
|
||||
fs.writeFileSync(path.join(sessionsDir, id + '.json'), JSON.stringify(session));
|
||||
return session;
|
||||
}
|
||||
seed(A); seed(B); seed('http-corrupt-chunk'); seed('http-corrupt-manifest');
|
||||
const corruptPath = path.join(sessionsDir, '_history', 'http-corrupt-chunk');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(corruptPath, 'manifest.json'), 'utf8'));
|
||||
fs.writeFileSync(path.join(corruptPath, 'chunks', manifest.entries.at(-1).file), '{破损块');
|
||||
fs.writeFileSync(path.join(sessionsDir, '_history', 'http-corrupt-manifest', 'manifest.json'), '{破损清单');
|
||||
const legacy = { ...seed('http-legacy'), id: 'http-legacy-only' };
|
||||
fs.writeFileSync(path.join(sessionsDir, legacy.id + '.json'), JSON.stringify(legacy));
|
||||
|
||||
// 原生目录也隔离,不覆写 HOME 等系统变量。
|
||||
const preload = path.join(root, 'isolated-home.cjs');
|
||||
fs.writeFileSync(preload, [
|
||||
"const fs = require('node:fs'); const Module = require('node:module');",
|
||||
"require('node:os').homedir = () => " + JSON.stringify(homeDir) + ';',
|
||||
// worker 也继承 preload;独立锁路径避免等待生产 app-server 的锁。
|
||||
'const locks = require(' + JSON.stringify(path.join(REPO, 'lib/codex-home-lock')) + ');',
|
||||
'const acquire = locks.acquireCodexHomeLock;',
|
||||
'locks.acquireCodexHomeLock = (options = {}) => acquire({ ...options, lockPath: ' + JSON.stringify(path.join(root, 'codex-fixture.lock')) + ' });',
|
||||
"const originalLoader = Module._extensions['.js'];",
|
||||
"Module._extensions['.js'] = (module, filename) => {",
|
||||
'if (filename !== ' + JSON.stringify(path.join(REPO, 'server.js')) + ') return originalLoader(module, filename);',
|
||||
"const source = fs.readFileSync(filename, 'utf8');",
|
||||
"const original = " + JSON.stringify("const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');") + ';',
|
||||
"if (!source.includes(original)) throw new Error('测试隔离目录注入点已变更');",
|
||||
'module._compile(source.replace(original, ' + JSON.stringify('const CODEX_SESSIONS_DIR = ' + JSON.stringify(path.join(homeDir, '.codex', 'sessions')) + ';') + '), filename); };',
|
||||
].join('\n'));
|
||||
const completeFlag = path.join(root, 'complete-turn');
|
||||
const mockPath = path.join(root, 'mock-codex.js');
|
||||
const mock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8');
|
||||
const timer = 'thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);';
|
||||
assert(mock.includes(timer));
|
||||
fs.writeFileSync(mockPath, mock.replace(timer, [
|
||||
"send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'agent-msg',delta:" + JSON.stringify(STREAM) + '}});',
|
||||
"thread.timer = setInterval(() => { if (!require('fs').existsSync(" + JSON.stringify(completeFlag) + ')) return;',
|
||||
'clearInterval(thread.timer); completeTurn(thread, turnId, text); }, 50);',
|
||||
].join('\n')));
|
||||
fs.chmodSync(mockPath, 0o700);
|
||||
return { sessionsDir, homeDir, store, preload, completeFlag, mockPath };
|
||||
}
|
||||
async function runApi(port, fixture) {
|
||||
let connection = await connect(port);
|
||||
try {
|
||||
const token = connection.token;
|
||||
const closed = new Promise(resolve => connection.ws.once('close', resolve));
|
||||
connection.ws.close();
|
||||
await closed;
|
||||
let sequence = 0;
|
||||
async function query(id, suffix = '', authenticated = true) {
|
||||
const requestId = 'api-' + ++sequence;
|
||||
const separator = suffix.includes('?') ? '&' : '?';
|
||||
const response = await fetch('http://127.0.0.1:' + port + '/api/sessions/' + id + suffix + separator + 'requestId=' + requestId, {
|
||||
headers: authenticated ? { Authorization: 'Bearer ' + token } : {},
|
||||
});
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.requestId, requestId);
|
||||
return { response, payload };
|
||||
}
|
||||
const first = await query(B);
|
||||
assert.equal(first.response.status, 200);
|
||||
assert.equal(first.payload.messages.length, 12);
|
||||
assert.equal(first.payload.historyCursor, 45);
|
||||
assert(first.response.headers.get('server-timing'));
|
||||
const ids = first.payload.messages.map(message => message.id);
|
||||
let cursor = first.payload.historyCursor;
|
||||
while (cursor > 0) {
|
||||
const page = await query(B, '/history?before=' + cursor);
|
||||
assert.equal(page.response.status, 200);
|
||||
assert(page.payload.messages.length <= 24);
|
||||
ids.unshift(...page.payload.messages.map(message => message.id));
|
||||
cursor = page.payload.historyCursor;
|
||||
}
|
||||
assert.deepEqual(ids, Array.from({ length: 57 }, (_, index) => B + '-' + index));
|
||||
assert.equal((await query(B, '', false)).response.status, 401);
|
||||
assert.equal((await query('does-not-exist')).response.status, 404);
|
||||
assert.equal((await query('bad%2Fid')).response.status, 400);
|
||||
assert.equal((await query('http_switch-a')).response.status, 400, '非法 ID 不能经 sanitizeId 别名到另一会话');
|
||||
assert.equal((await query('%ZZ')).response.status, 400);
|
||||
assert.equal((await query(B, '/history?before=-1')).response.status, 400);
|
||||
assert.equal((await query(B, '/history?before=1.5')).response.status, 400);
|
||||
for (const id of ['http-corrupt-chunk', 'http-corrupt-manifest']) {
|
||||
const result = await query(id);
|
||||
assert.equal(result.response.status, 500);
|
||||
assert.equal(result.payload.code, 'history_read_failed');
|
||||
assert.equal(result.payload.sessionId, id);
|
||||
}
|
||||
assert.equal((await query('http-legacy-only')).payload.messages.length, 12);
|
||||
connection = await connect(port);
|
||||
connection.ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: B,
|
||||
requestId: 'subscribe-before-commit', historyRevision: first.payload.historyRevision }));
|
||||
const unchanged = await waitFor(() => connection.messages.find(msg => msg.requestId === 'subscribe-before-commit'), '订阅结果缺失');
|
||||
assert.equal(unchanged.type, 'session_subscribed');
|
||||
assert.equal(unchanged.historyChanged, false);
|
||||
assert.equal(unchanged.messages, undefined, '实时订阅不能重发历史');
|
||||
const changed = fixture.store.loadMetadata(B);
|
||||
changed.messages.push({ id: 'http-new-between-query-and-subscribe', role: 'assistant', content: '订阅间隙的新消息' });
|
||||
fixture.store.save(changed);
|
||||
connection.ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: B,
|
||||
requestId: 'subscribe-after-commit', historyRevision: first.payload.historyRevision }));
|
||||
const advanced = await waitFor(() => connection.messages.find(msg => msg.requestId === 'subscribe-after-commit'), '新修订订阅结果缺失');
|
||||
assert.equal(advanced.historyChanged, true);
|
||||
assert.equal(advanced.messages, undefined);
|
||||
assert.equal((await query(B)).payload.messages.at(-1).id, 'http-new-between-query-and-subscribe');
|
||||
connection.ws.send(JSON.stringify({ type: 'message', sessionId: A, agent: 'codexapp', mode: 'yolo', text: 'HTTP 会话切换运行夹具' }));
|
||||
const running = await waitFor(async () => {
|
||||
const result = await query(A);
|
||||
return result.payload.isRunning && result.payload.runtime?.text.includes(STREAM) ? result.payload : null;
|
||||
}, 'HTTP 运行态快照缺失');
|
||||
assert.equal(running.historyPending, false);
|
||||
assert.equal(running.messages.length, 12);
|
||||
assert.equal(running.runtime.type, 'resume_generating');
|
||||
assert(!connection.messages.some(msg => msg.type === 'session_history_chunk'));
|
||||
return { connection, token, query };
|
||||
} catch (error) {
|
||||
connection.ws.terminate();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function main() {
|
||||
await require('./session-http-load-frontend-unit').runFrontendUnit();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-session-http-'));
|
||||
const fixture = createFixture(root);
|
||||
const port = await freePort();
|
||||
const server = spawn(process.execPath, ['-r', fixture.preload, path.join(REPO, 'server.js')], {
|
||||
cwd: REPO, env: { ...process.env, PORT: String(port), CC_WEB_PASSWORD: PASSWORD,
|
||||
CC_WEB_INTERNAL_MCP_TOKEN: 'HttpFixtureMcp!234', CC_WEB_TASK_BOARD_ENABLED: '0',
|
||||
CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_LOGS_DIR: path.join(root, 'logs'),
|
||||
CC_WEB_SESSIONS_DIR: fixture.sessionsDir, CODEX_PATH: fixture.mockPath,
|
||||
CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'),
|
||||
}, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const log = fs.createWriteStream(path.join(root, 'server.log'));
|
||||
server.stdout.pipe(log); server.stderr.pipe(log);
|
||||
let api;
|
||||
try {
|
||||
await waitFor(async () => (await fetch('http://127.0.0.1:' + port + '/')).ok, '隔离服务启动失败');
|
||||
api = await runApi(port, fixture);
|
||||
console.log('HTTP 接口通过:WS 关闭后首屏12条、分页24条、顺序、鉴权、请求级错误、独立订阅、修订及运行快照');
|
||||
if (process.argv.includes('--browser')) {
|
||||
await require('./session-http-load-browser').runBrowser({ port, fixture, root, query: api.query,
|
||||
A, B, PASSWORD, STREAM, waitFor, freePort, stopChild, sleep });
|
||||
}
|
||||
const processLog = fs.readFileSync(path.join(root, 'logs', 'process.log'), 'utf8');
|
||||
assert(processLog.includes('session_http_query'));
|
||||
assert(processLog.includes('session_http_query_failed'));
|
||||
assert(!processLog.includes(STREAM), '诊断日志不得记录消息正文');
|
||||
console.log('HTTP 耗时诊断通过;隔离证据:' + root);
|
||||
} finally {
|
||||
if (api) api.connection.ws.terminate();
|
||||
await stopChild(server);
|
||||
log.end();
|
||||
}
|
||||
}
|
||||
main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user