263 lines
16 KiB
JavaScript
263 lines
16 KiB
JavaScript
#!/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 };
|