#!/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; });