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

263 lines
16 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.

#!/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, options = {}) {
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');
if (options.largeTools !== false) {
const large = seed('http-large-tools', 17);
large.messages[15].toolCalls = Array.from({ length: 100 }, (_, index) => ({
id: 'large-tool-' + index, name: 'exec_command', kind: 'command_execution', done: true,
input: { command: '读取完整工具记录', payload: '输入'.repeat(10000) },
result: '结果起点' + '完整输出'.repeat(10000) + '结果终点', meta: { exitCode: 0 },
}));
large.messages[3].toolCalls = [{ id: 'older-tool', name: 'read_file', done: true,
input: { path: '/隔离夹具' }, result: '早期完整结果'.repeat(10000) }];
delete large.messages[3].id;
store.save(large);
}
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) + '}});',
"send({method:'item/started',params:{threadId:thread.id,turnId,item:{id:'large-runtime-tool',type:'commandExecution',command:'运行工具夹具',status:'inProgress'}}});",
"send({method:'item/completed',params:{threadId:thread.id,turnId,item:{id:'large-runtime-tool',type:'commandExecution',command:'运行工具夹具',aggregatedOutput:'运行结果起点'+'完整运行输出'.repeat(100000)+'运行结果终点',exitCode:0,status:'completed'}}});",
"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);
const large = (await query('http-large-tools')).payload;
const summary = large.messages.find(message => message.toolCalls?.length === 100);
assert(summary, '首屏必须保留全部100个工具概要');
assert(Buffer.byteLength(JSON.stringify(large)) < 100000, '多兆工具输出不能绑在首屏');
assert(!summary.toolCalls[0].result);
const detailSuffix = ref => '?toolDetail=' + encodeURIComponent(JSON.stringify(ref));
const detail = await query('http-large-tools', detailSuffix(summary.toolCalls[0].detailRef));
assert.deepEqual(detail.payload.tool, fixture.store.load('http-large-tools').messages[15].toolCalls[0], '输入输出详情必须完整');
assert.equal((await query(B, detailSuffix(summary.toolCalls[0].detailRef))).response.status, 400, '不能跨会话读取');
assert.equal((await query('http-large-tools', detailSuffix({ ...summary.toolCalls[0].detailRef, signature: '过期' }))).response.status, 409);
assert.equal((await query('http-large-tools', '?toolDetail=broken')).response.status, 400);
assert.equal((await query('http-large-tools', detailSuffix(summary.toolCalls[0].detailRef), false)).response.status, 401);
const older = (await query('http-large-tools', '/history?before=' + large.historyCursor)).payload;
const olderTool = older.messages.find(message => message.toolCalls?.length)?.toolCalls[0];
assert.equal((await query('http-large-tools', detailSuffix(olderTool.detailRef))).payload.tool.result, '早期完整结果'.repeat(10000));
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');
const runtime = await waitFor(async () => {
const snapshot = (await query(A)).payload.runtime;
return snapshot?.toolCalls?.some(tool => tool.kind === 'command_execution' && tool.done && tool.detailRef) ? snapshot : null;
}, '运行工具概要缺失');
assert(Buffer.byteLength(JSON.stringify(runtime)) < 10000, '运行快照不能捆绑完整工具输出');
const runtimeTool = runtime.toolCalls.find(tool => tool.kind === 'command_execution' && tool.done && tool.detailRef);
const runtimeDetail = (await query(A, detailSuffix(runtimeTool.detailRef))).payload.tool;
assert(runtimeDetail.result.includes('运行结果终点'), '运行工具详情不能被预览限制裁剪');
connection.ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: A, requestId: 'compact-runtime' }));
const resumed = await waitFor(() => connection.messages.find(msg => msg.type === 'resume_generating' && msg.requestId === 'compact-runtime'), '订阅运行快照缺失');
assert(Buffer.byteLength(JSON.stringify(resumed)) < 10000, 'WS不能再次重放完整工具输出');
assert(resumed.toolCalls.some(tool => tool.detailRef));
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();
}
}
if (require.main === module) main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });
module.exports = { createFixture, waitFor, freePort, stopChild, connect, sleep, PASSWORD };