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

220 lines
12 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) {
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; });