473 lines
17 KiB
JavaScript
473 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
const assert = require('assert/strict');
|
||
const crypto = require('crypto');
|
||
const fs = require('fs');
|
||
const net = require('net');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const { spawn } = require('child_process');
|
||
|
||
const REPO_DIR = path.resolve(__dirname, '..');
|
||
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
|
||
const { TOOLS } = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server'));
|
||
|
||
const INTERNAL_MCP_TOKEN = 'ListUserInputsUnitMcp!234';
|
||
|
||
function mkdirp(dir) {
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
function getFreePort() {
|
||
return new Promise((resolve, reject) => {
|
||
const server = net.createServer();
|
||
server.on('error', reject);
|
||
server.listen(0, '127.0.0.1', () => {
|
||
const address = server.address();
|
||
const port = address && typeof address === 'object' ? address.port : null;
|
||
server.close(() => resolve(port));
|
||
});
|
||
});
|
||
}
|
||
|
||
async function waitForPort(port, timeoutMs = 10000) {
|
||
const started = Date.now();
|
||
let lastError = null;
|
||
while (Date.now() - started < timeoutMs) {
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||
socket.once('connect', () => {
|
||
socket.destroy();
|
||
resolve();
|
||
});
|
||
socket.once('error', reject);
|
||
socket.setTimeout(500, () => {
|
||
socket.destroy();
|
||
reject(new Error('timeout'));
|
||
});
|
||
});
|
||
return;
|
||
} catch (err) {
|
||
lastError = err;
|
||
await sleep(50);
|
||
}
|
||
}
|
||
throw new Error(`Timed out waiting for port ${port}: ${lastError?.message || 'unknown'}`);
|
||
}
|
||
|
||
async function withServer(env, fn) {
|
||
const child = spawn(process.execPath, [SERVER_PATH], {
|
||
cwd: REPO_DIR,
|
||
env: { ...process.env, ...env },
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
||
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
||
|
||
try {
|
||
await waitForPort(env.PORT, 10000);
|
||
await fn({ stdout: () => stdout, stderr: () => stderr });
|
||
} finally {
|
||
if (child.exitCode === null && !child.signalCode) {
|
||
child.kill('SIGTERM');
|
||
await sleep(300);
|
||
}
|
||
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
|
||
}
|
||
}
|
||
|
||
async function postJson(port, pathname, body) {
|
||
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CC-Web-MCP-Token': INTERNAL_MCP_TOKEN,
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
let payload = null;
|
||
try {
|
||
payload = await response.json();
|
||
} catch {}
|
||
return { status: response.status, payload };
|
||
}
|
||
|
||
async function mcpJsonRpc(port, sourceSessionId, message) {
|
||
const query = sourceSessionId ? `?sourceSessionId=${encodeURIComponent(sourceSessionId)}` : '';
|
||
return postJson(port, `/api/internal/mcp/stream${query}`, message);
|
||
}
|
||
|
||
async function callSharedTool(port, sourceSessionId, args = {}) {
|
||
const response = await mcpJsonRpc(port, sourceSessionId, {
|
||
jsonrpc: '2.0',
|
||
id: crypto.randomUUID(),
|
||
method: 'tools/call',
|
||
params: {
|
||
name: 'ccweb_list_user_inputs',
|
||
arguments: args,
|
||
},
|
||
});
|
||
assert.equal(response.status, 200, `tools/call should return HTTP 200, got ${response.status}`);
|
||
assert.ok(response.payload?.result, 'tools/call should return a JSON-RPC result');
|
||
return response.payload.result.structuredContent;
|
||
}
|
||
|
||
async function directInternalCall(port, sourceSessionId, args = {}) {
|
||
const response = await postJson(port, '/api/internal/mcp', {
|
||
tool: 'ccweb_list_user_inputs',
|
||
args,
|
||
sourceSessionId,
|
||
});
|
||
return response;
|
||
}
|
||
|
||
function writeSession(sessionsDir, session) {
|
||
const filePath = path.join(sessionsDir, `${session.id}.json`);
|
||
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
|
||
return filePath;
|
||
}
|
||
|
||
function sha256(filePath) {
|
||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||
}
|
||
|
||
function userMessage(id, content) {
|
||
return {
|
||
id,
|
||
role: 'user',
|
||
content,
|
||
timestamp: `2026-08-17T00:${String(Number(id.replace(/\D/g, '')) || 0).padStart(2, '0')}:00.000Z`,
|
||
};
|
||
}
|
||
|
||
function createFixtures(sessionsDir) {
|
||
const sourceMessages = [];
|
||
for (let index = 1; index <= 18; index += 1) {
|
||
sourceMessages.push(userMessage(`u${String(index).padStart(2, '0')}`, `用户输入 ${String(index).padStart(2, '0')}`));
|
||
}
|
||
sourceMessages.splice(4, 0,
|
||
{ id: 'assistant-guidance', role: 'assistant', content: 'assistant 说明不应进入结果', timestamp: '2026-08-17T00:04:10.000Z' },
|
||
{ id: 'tool-result', role: 'tool', content: '工具输出不应进入结果', timestamp: '2026-08-17T00:04:20.000Z' },
|
||
{ id: 'blank-user', role: 'user', content: ' \n\t', timestamp: '2026-08-17T00:04:30.000Z' },
|
||
{
|
||
id: 'assistant-prompt-card',
|
||
role: 'assistant',
|
||
content: '',
|
||
timestamp: '2026-08-17T00:04:40.000Z',
|
||
ccwebPrompt: {
|
||
id: 'prompt-1',
|
||
status: 'pending',
|
||
title: '需要确认',
|
||
questions: [{ id: 'q1', title: '方向', question: '选择方向' }],
|
||
},
|
||
},
|
||
);
|
||
sourceMessages.push(userMessage('submitted-form', '表单答案:\n\n表单:需要确认\n\n1. 方向\n答案:继续实现最小方案'));
|
||
|
||
const sourcePath = writeSession(sessionsDir, {
|
||
id: 'source-session',
|
||
title: '来源会话',
|
||
agent: 'codexapp',
|
||
created: '2026-08-17T00:00:00.000Z',
|
||
updated: '2026-08-17T00:30:00.000Z',
|
||
hasUnread: false,
|
||
isRunning: false,
|
||
messages: sourceMessages,
|
||
});
|
||
|
||
const explicitPath = writeSession(sessionsDir, {
|
||
id: 'explicit-session',
|
||
title: '显式会话',
|
||
agent: 'codexapp',
|
||
created: '2026-08-17T01:00:00.000Z',
|
||
updated: '2026-08-17T01:10:00.000Z',
|
||
hasUnread: true,
|
||
isRunning: false,
|
||
messages: [
|
||
userMessage('explicit-old', '显式旧输入'),
|
||
{ id: 'explicit-assistant', role: 'assistant', content: '显式 assistant' },
|
||
userMessage('explicit-new', '显式新输入'),
|
||
],
|
||
});
|
||
|
||
const unicodePath = writeSession(sessionsDir, {
|
||
id: 'unicode-session',
|
||
title: 'Unicode 会话',
|
||
agent: 'codexapp',
|
||
created: '2026-08-17T02:00:00.000Z',
|
||
updated: '2026-08-17T02:10:00.000Z',
|
||
messages: [
|
||
userMessage('unicode-old', 'abcdefghij'),
|
||
userMessage('unicode-mid', '一二三四'),
|
||
userMessage('unicode-latest', '🙂🙂🙂🙂'),
|
||
],
|
||
});
|
||
|
||
const exactSinglePath = writeSession(sessionsDir, {
|
||
id: 'exact-single-session',
|
||
title: '恰好预算单消息',
|
||
agent: 'codexapp',
|
||
messages: [userMessage('exact-single', '🙂甲')],
|
||
});
|
||
|
||
const exactMorePath = writeSession(sessionsDir, {
|
||
id: 'exact-more-session',
|
||
title: '恰好预算仍有更早消息',
|
||
agent: 'codexapp',
|
||
messages: [
|
||
userMessage('exact-old', '旧'),
|
||
userMessage('exact-latest', '🙂甲'),
|
||
],
|
||
});
|
||
|
||
const emptyPath = writeSession(sessionsDir, {
|
||
id: 'empty-session',
|
||
title: '无有效用户正文',
|
||
agent: 'codexapp',
|
||
messages: [
|
||
{ id: 'empty-assistant', role: 'assistant', content: '仅 assistant' },
|
||
{ id: 'empty-user', role: 'user', content: ' \n\t ' },
|
||
],
|
||
});
|
||
|
||
return {
|
||
sourcePath,
|
||
explicitPath,
|
||
unicodePath,
|
||
exactSinglePath,
|
||
exactMorePath,
|
||
emptyPath,
|
||
};
|
||
}
|
||
|
||
function assertToolSchema() {
|
||
const tool = TOOLS.find((item) => item.name === 'ccweb_list_user_inputs');
|
||
assert.ok(tool, '正式 ccweb MCP tools/list 应暴露 ccweb_list_user_inputs');
|
||
assert.equal(tool.description, '该工具返回用户输入的近 N 次对话列表。');
|
||
|
||
const schema = tool.inputSchema;
|
||
assert.equal(schema?.type, 'object');
|
||
assert.equal(schema.additionalProperties, false, 'schema additionalProperties 必须为 false');
|
||
assert.ok(!Array.isArray(schema.required) || !schema.required.includes('conversationId'), 'conversationId 必须可选');
|
||
assert.deepEqual(schema.properties?.conversationId?.type, 'string');
|
||
assert.equal(schema.properties?.limit?.type, 'integer');
|
||
assert.equal(schema.properties?.limit?.minimum, 1);
|
||
assert.equal(schema.properties?.limit?.maximum, 50);
|
||
assert.match(schema.properties?.limit?.description || '', /默认\s*15|15/);
|
||
assert.equal(schema.properties?.maxChars?.type, 'integer');
|
||
assert.equal(schema.properties?.maxChars?.minimum, 1);
|
||
assert.equal(schema.properties?.maxChars?.maximum, 1000);
|
||
assert.match(schema.properties?.maxChars?.description || '', /默认\s*1000|1000/);
|
||
}
|
||
|
||
async function assertServerToolList(port) {
|
||
const response = await mcpJsonRpc(port, 'source-session', {
|
||
jsonrpc: '2.0',
|
||
id: 'tools-list',
|
||
method: 'tools/list',
|
||
params: {},
|
||
});
|
||
assert.equal(response.status, 200);
|
||
const toolNames = response.payload?.result?.tools?.map((tool) => tool.name) || [];
|
||
assert.ok(toolNames.includes('ccweb_list_user_inputs'), '内部 JSON-RPC tools/list 应包含 ccweb_list_user_inputs');
|
||
}
|
||
|
||
async function assertDefaultsAndFiltering(port) {
|
||
const payload = await callSharedTool(port, 'source-session', {});
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.conversationId, 'source-session');
|
||
assert.equal(payload.requestedLimit, 15);
|
||
assert.equal(payload.maxChars, 1000);
|
||
assert.equal(payload.returnedCount, 15);
|
||
assert.equal(payload.items.length, 15);
|
||
assert.equal(payload.hasMore, true, '超过默认 limit 的更早用户输入应设置 hasMore=true');
|
||
assert.deepEqual(payload.items.map((item) => item.messageId), [
|
||
'u05', 'u06', 'u07', 'u08', 'u09',
|
||
'u10', 'u11', 'u12', 'u13', 'u14',
|
||
'u15', 'u16', 'u17', 'u18', 'submitted-form',
|
||
]);
|
||
const allContent = payload.items.map((item) => item.content).join('\n');
|
||
assert.match(allContent, /表单答案/, '提交后的普通 user 消息应进入结果');
|
||
assert.doesNotMatch(allContent, /assistant 说明|工具输出/, 'assistant/tool 独有内容不应进入结果');
|
||
assert.ok(
|
||
!payload.items.some((item) => item.messageId === 'assistant-prompt-card'),
|
||
'未提交的 assistant 引导卡不应以 messageId 进入结果'
|
||
);
|
||
assert.ok(payload.totalChars <= 1000);
|
||
for (const item of payload.items) {
|
||
assert.equal(item.contentChars, Array.from(item.content).length);
|
||
assert.equal(item.truncated, false);
|
||
}
|
||
}
|
||
|
||
async function assertExplicitConversation(port) {
|
||
const payload = await callSharedTool(port, 'source-session', { conversationId: 'explicit-session', limit: 10 });
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.conversationId, 'explicit-session');
|
||
assert.deepEqual(payload.items.map((item) => item.content), ['显式旧输入', '显式新输入']);
|
||
}
|
||
|
||
async function assertUnicodeBudgetAndTruncation(port) {
|
||
const payload = await callSharedTool(port, 'source-session', {
|
||
conversationId: 'unicode-session',
|
||
limit: 3,
|
||
maxChars: 6,
|
||
});
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.totalChars, 6);
|
||
assert.equal(payload.returnedCount, 2);
|
||
assert.equal(payload.hasMore, true);
|
||
assert.deepEqual(payload.items.map((item) => item.messageId), ['unicode-mid', 'unicode-latest']);
|
||
assert.equal(payload.items[0].content, '一二');
|
||
assert.equal(payload.items[0].contentChars, 2);
|
||
assert.equal(payload.items[0].originalChars, 4);
|
||
assert.equal(payload.items[0].truncated, true);
|
||
assert.equal(payload.items[1].content, '🙂🙂🙂🙂');
|
||
assert.equal(payload.items[1].contentChars, 4, 'emoji 应按 Unicode code point 计数,而不是 UTF-16 code unit');
|
||
assert.equal(payload.items[1].originalChars, 4);
|
||
assert.equal(payload.items[1].truncated, false);
|
||
assert.ok(payload.items.reduce((sum, item) => sum + item.contentChars, 0) <= 6);
|
||
}
|
||
|
||
async function assertExactBudgetEmptyAndClamps(port) {
|
||
const exactSingle = await callSharedTool(port, 'source-session', {
|
||
conversationId: 'exact-single-session',
|
||
maxChars: 2,
|
||
});
|
||
assert.equal(exactSingle.totalChars, 2);
|
||
assert.equal(exactSingle.hasMore, false, '恰好用满预算且无更早输入时不应误报 hasMore');
|
||
assert.equal(exactSingle.items[0].truncated, false);
|
||
|
||
const exactMore = await callSharedTool(port, 'source-session', {
|
||
conversationId: 'exact-more-session',
|
||
maxChars: 2,
|
||
});
|
||
assert.equal(exactMore.totalChars, 2);
|
||
assert.equal(exactMore.returnedCount, 1);
|
||
assert.equal(exactMore.items[0].messageId, 'exact-latest');
|
||
assert.equal(exactMore.items[0].truncated, false);
|
||
assert.equal(exactMore.hasMore, true, '恰好用满预算但仍有更早输入时应标记 hasMore');
|
||
|
||
const empty = await callSharedTool(port, 'source-session', { conversationId: 'empty-session' });
|
||
assert.equal(empty.returnedCount, 0);
|
||
assert.equal(empty.totalChars, 0);
|
||
assert.equal(empty.hasMore, false);
|
||
assert.deepEqual(empty.items, []);
|
||
|
||
const clamped = await callSharedTool(port, 'source-session', {
|
||
conversationId: 'explicit-session',
|
||
limit: 999,
|
||
maxChars: 9999,
|
||
});
|
||
assert.equal(clamped.requestedLimit, 50);
|
||
assert.equal(clamped.maxChars, 1000);
|
||
}
|
||
|
||
async function assertErrors(port) {
|
||
const missing = await callSharedTool(port, '', {});
|
||
assert.equal(missing.ok, false);
|
||
assert.equal(missing.code, 'missing_source_conversation');
|
||
|
||
const invalid = await callSharedTool(port, 'source-session', { conversationId: '!!!', limit: 5 });
|
||
assert.equal(invalid.ok, false);
|
||
assert.equal(invalid.code, 'invalid_conversation_id');
|
||
|
||
const notFound = await callSharedTool(port, 'source-session', { conversationId: 'missing-session', limit: 5 });
|
||
assert.equal(notFound.ok, false);
|
||
assert.equal(notFound.code, 'conversation_not_found');
|
||
}
|
||
|
||
async function assertDirectInternalRoute(port) {
|
||
const response = await directInternalCall(port, 'source-session', { limit: 1 });
|
||
assert.equal(response.status, 200);
|
||
assert.equal(response.payload?.ok, true);
|
||
assert.equal(response.payload?.conversationId, 'source-session');
|
||
assert.equal(response.payload?.returnedCount, 1);
|
||
}
|
||
|
||
async function runTest(name, fn, failures) {
|
||
try {
|
||
await fn();
|
||
console.log(`ok - ${name}`);
|
||
} catch (err) {
|
||
failures.push({ name, err });
|
||
console.error(`not ok - ${name}`);
|
||
console.error(` ${err.stack || err.message}`);
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const failures = [];
|
||
await runTest('正式 MCP schema', assertToolSchema, failures);
|
||
|
||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-list-user-inputs-'));
|
||
try {
|
||
const sessionsDir = path.join(tempRoot, 'sessions');
|
||
const configDir = path.join(tempRoot, 'config');
|
||
const logsDir = path.join(tempRoot, 'logs');
|
||
mkdirp(sessionsDir);
|
||
mkdirp(configDir);
|
||
mkdirp(logsDir);
|
||
const fixturePaths = createFixtures(sessionsDir);
|
||
const before = Object.fromEntries(Object.entries(fixturePaths).map(([key, filePath]) => {
|
||
const stat = fs.statSync(filePath);
|
||
return [key, { filePath, hash: sha256(filePath), mtimeMs: stat.mtimeMs }];
|
||
}));
|
||
|
||
const port = await getFreePort();
|
||
await withServer({
|
||
PORT: String(port),
|
||
CC_WEB_PASSWORD: 'ListUserInputsUnit!234',
|
||
CC_WEB_INTERNAL_MCP_TOKEN: INTERNAL_MCP_TOKEN,
|
||
CC_WEB_CONFIG_DIR: configDir,
|
||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||
CC_WEB_LOGS_DIR: logsDir,
|
||
CC_WEB_USAGE_STATISTICS: '0',
|
||
}, async () => {
|
||
await runTest('内部 JSON-RPC tools/list 暴露工具', () => assertServerToolList(port), failures);
|
||
await runTest('默认来源会话、最近 15 条、过滤和旧到新排序', () => assertDefaultsAndFiltering(port), failures);
|
||
await runTest('显式指定 conversationId 查询目标会话', () => assertExplicitConversation(port), failures);
|
||
await runTest('maxChars Unicode code point 预算和截断字段', () => assertUnicodeBudgetAndTruncation(port), failures);
|
||
await runTest('恰好预算、空会话和运行时 clamp 边界', () => assertExactBudgetEmptyAndClamps(port), failures);
|
||
await runTest('错误码 missing_source_conversation / invalid_conversation_id / conversation_not_found', () => assertErrors(port), failures);
|
||
await runTest('旧版内部 /api/internal/mcp 路由分派', () => assertDirectInternalRoute(port), failures);
|
||
});
|
||
|
||
await runTest('查询不修改 fixture 会话文件', () => {
|
||
for (const entry of Object.values(before)) {
|
||
const stat = fs.statSync(entry.filePath);
|
||
assert.equal(sha256(entry.filePath), entry.hash, `${path.basename(entry.filePath)} 内容不应变化`);
|
||
assert.equal(stat.mtimeMs, entry.mtimeMs, `${path.basename(entry.filePath)} mtime 不应变化`);
|
||
}
|
||
}, failures);
|
||
} finally {
|
||
try {
|
||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||
} catch (err) {
|
||
failures.push({ name: '清理临时目录', err });
|
||
console.error('not ok - 清理临时目录');
|
||
console.error(` ${err.stack || err.message}`);
|
||
}
|
||
}
|
||
|
||
if (failures.length > 0) {
|
||
console.error(`\n${failures.length} test(s) failed.`);
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
console.log('\nall ccweb_list_user_inputs focused tests passed');
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error(err.stack || err.message);
|
||
process.exit(1);
|
||
});
|