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