fix: 重构 资源的注册,发现,解析架构,解决兼容性问题

This commit is contained in:
sean
2025-06-12 12:28:53 +08:00
parent 88874ff7ec
commit 5d6e678bd2
15 changed files with 2029 additions and 1354 deletions

View File

@ -1,181 +0,0 @@
const { MCPStreamableHttpCommand } = require('../../lib/commands/MCPStreamableHttpCommand');
const http = require('http');
describe('MCP SSE Server Integration Tests', () => {
let command;
let port;
beforeEach(() => {
command = new MCPStreamableHttpCommand();
port = 3001 + Math.floor(Math.random() * 1000);
});
afterEach(async () => {
if (command.server && command.server.close) {
await new Promise((resolve) => {
command.server.close(resolve);
});
}
});
describe('SSE Transport', () => {
it('should start SSE server and handle dual endpoints', async () => {
// 启动 SSE 服务器
await command.execute({
transport: 'sse',
port,
host: 'localhost'
});
// 等待服务器启动
await new Promise(resolve => setTimeout(resolve, 200));
// 测试健康检查端点
const healthResponse = await makeHttpRequest({
hostname: 'localhost',
port,
path: '/health',
method: 'GET'
});
expect(healthResponse.statusCode).toBe(200);
const healthData = JSON.parse(healthResponse.data);
expect(healthData.status).toBe('ok');
}, 10000);
it('should establish SSE stream on GET /mcp', async () => {
await command.execute({ transport: 'sse', port, host: 'localhost' });
await new Promise(resolve => setTimeout(resolve, 200));
// 尝试建立 SSE 连接
const sseResponse = await makeHttpRequest({
hostname: 'localhost',
port,
path: '/mcp',
method: 'GET',
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache'
}
});
expect(sseResponse.statusCode).toBe(200);
expect(sseResponse.headers['content-type']).toContain('text/event-stream');
}, 10000);
it('should handle POST messages to /messages endpoint', async () => {
await command.execute({ transport: 'sse', port, host: 'localhost' });
await new Promise(resolve => setTimeout(resolve, 200));
// 先建立 SSE 连接获取会话ID
const sseResponse = await makeHttpRequest({
hostname: 'localhost',
port,
path: '/mcp',
method: 'GET',
headers: { 'Accept': 'text/event-stream' }
});
// 解析 SSE 响应获取会话ID
const sseData = sseResponse.data;
const endpointMatch = sseData.match(/event: endpoint\ndata: (.+)/);
let sessionId = 'test-session';
if (endpointMatch) {
const endpointData = JSON.parse(endpointMatch[1]);
const urlObj = new URL(endpointData.uri);
sessionId = urlObj.searchParams.get('sessionId');
}
// 发送初始化请求到 /messages 端点
const initRequest = {
jsonrpc: '2.0',
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' }
},
id: 1
};
const response = await makeHttpRequest({
hostname: 'localhost',
port,
path: `/messages?sessionId=${sessionId}`,
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}, JSON.stringify(initRequest));
expect(response.statusCode).toBe(200);
}, 10000);
});
describe('Transport Type Selection', () => {
it('should start different transports based on parameter', async () => {
// 测试默认 HTTP 传输
const httpCommand = new MCPStreamableHttpCommand();
const httpPort = port + 100;
await httpCommand.execute({ transport: 'http', port: httpPort });
const httpHealth = await makeHttpRequest({
hostname: 'localhost',
port: httpPort,
path: '/health',
method: 'GET'
});
expect(httpHealth.statusCode).toBe(200);
// 清理
if (httpCommand.server) {
await new Promise(resolve => httpCommand.server.close(resolve));
}
// 测试 SSE 传输
const sseCommand = new MCPStreamableHttpCommand();
const ssePort = port + 200;
await sseCommand.execute({ transport: 'sse', port: ssePort });
const sseHealth = await makeHttpRequest({
hostname: 'localhost',
port: ssePort,
path: '/health',
method: 'GET'
});
expect(sseHealth.statusCode).toBe(200);
// 清理
if (sseCommand.server) {
await new Promise(resolve => sseCommand.server.close(resolve));
}
}, 15000);
});
});
// Helper function to make HTTP requests
function makeHttpRequest(options, data = null) {
return new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
resolve({
statusCode: res.statusCode,
headers: res.headers,
data: responseData
});
});
});
req.on('error', reject);
if (data) {
req.write(data);
}
req.end();
});
}

View File

@ -14,15 +14,22 @@ describe('MCPStreamableHttpCommand Integration Tests', () => {
afterEach(async () => {
if (server && server.close) {
await new Promise((resolve) => {
server.close(resolve);
server.close(() => {
server = null;
resolve();
});
});
}
// 清理命令实例
if (command && command.server) {
command.server = null;
}
});
describe('Streamable HTTP Server', () => {
it('should start server and respond to health check', async () => {
// 启动服务器
const serverPromise = command.execute({
server = await command.execute({
transport: 'http',
port,
host: 'localhost'
@ -44,7 +51,7 @@ describe('MCPStreamableHttpCommand Integration Tests', () => {
it('should handle MCP initialize request', async () => {
// 启动服务器
await command.execute({
server = await command.execute({
transport: 'http',
port,
host: 'localhost'
@ -87,7 +94,7 @@ describe('MCPStreamableHttpCommand Integration Tests', () => {
it('should handle tools/list request', async () => {
// 启动服务器
await command.execute({
server = await command.execute({
transport: 'http',
port,
host: 'localhost'
@ -119,7 +126,12 @@ describe('MCPStreamableHttpCommand Integration Tests', () => {
}
}, JSON.stringify(initRequest));
const sessionId = JSON.parse(initResponse.data).result?.sessionId;
const initResponseData = JSON.parse(initResponse.data);
const sessionId = initResponse.headers['mcp-session-id'];
if (!sessionId) {
throw new Error('Session ID not found in initialization response headers. Headers: ' + JSON.stringify(initResponse.headers) + ', Body: ' + JSON.stringify(initResponseData));
}
// 发送工具列表请求
const toolsRequest = {
@ -137,7 +149,7 @@ describe('MCPStreamableHttpCommand Integration Tests', () => {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'mcp-session-id': sessionId || 'test-session'
'mcp-session-id': sessionId
}
}, JSON.stringify(toolsRequest));
@ -150,7 +162,7 @@ describe('MCPStreamableHttpCommand Integration Tests', () => {
it('should handle tool call request', async () => {
// 启动服务器
await command.execute({
server = await command.execute({
transport: 'http',
port,
host: 'localhost'
@ -159,6 +171,36 @@ describe('MCPStreamableHttpCommand Integration Tests', () => {
// 等待服务器启动
await new Promise(resolve => setTimeout(resolve, 100));
// 先初始化
const initRequest = {
jsonrpc: '2.0',
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' }
},
id: 1
};
const initResponse = await makeHttpRequest({
hostname: 'localhost',
port,
path: '/mcp',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream'
}
}, JSON.stringify(initRequest));
const initResponseData = JSON.parse(initResponse.data);
const sessionId = initResponse.headers['mcp-session-id'];
if (!sessionId) {
throw new Error('Session ID not found in initialization response headers. Headers: ' + JSON.stringify(initResponse.headers));
}
// 发送工具调用请求
const toolCallRequest = {
jsonrpc: '2.0',
@ -178,7 +220,7 @@ describe('MCPStreamableHttpCommand Integration Tests', () => {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'mcp-session-id': 'test-session'
'mcp-session-id': sessionId
}
}, JSON.stringify(toolCallRequest));
@ -213,8 +255,11 @@ describe('MCPStreamableHttpCommand Integration Tests', () => {
const request = {
jsonrpc: '2.0',
method: 'tools/list',
params: {},
method: 'tools/call',
params: {
name: 'promptx_hello',
arguments: {}
},
id: 1
};
@ -237,6 +282,11 @@ describe('MCPStreamableHttpCommand Integration Tests', () => {
// Helper function to make HTTP requests
function makeHttpRequest(options, data = null) {
return new Promise((resolve, reject) => {
// 如果有数据添加Content-Length header
if (data && options.headers) {
options.headers['Content-Length'] = Buffer.byteLength(data);
}
const req = http.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => {

View File

@ -0,0 +1,192 @@
const path = require('path')
const fs = require('fs')
const ProtocolResolver = require('../../../lib/core/resource/ProtocolResolver')
describe('ProtocolResolver', () => {
let resolver
beforeEach(() => {
resolver = new ProtocolResolver()
})
describe('parseReference', () => {
test('should parse valid @package:// reference', () => {
const result = resolver.parseReference('@package://prompt/core/role.md')
expect(result.protocol).toBe('package')
expect(result.resourcePath).toBe('prompt/core/role.md')
expect(result.loadingSemantic).toBe('')
expect(result.fullReference).toBe('@package://prompt/core/role.md')
})
test('should parse valid @project:// reference', () => {
const result = resolver.parseReference('@project://.promptx/custom.role.md')
expect(result.protocol).toBe('project')
expect(result.resourcePath).toBe('.promptx/custom.role.md')
expect(result.loadingSemantic).toBe('')
})
test('should parse valid @file:// reference', () => {
const result = resolver.parseReference('@file:///absolute/path/to/file.md')
expect(result.protocol).toBe('file')
expect(result.resourcePath).toBe('/absolute/path/to/file.md')
expect(result.loadingSemantic).toBe('')
})
test('should parse @! hot loading semantic', () => {
const result = resolver.parseReference('@!package://prompt/core/role.md')
expect(result.protocol).toBe('package')
expect(result.resourcePath).toBe('prompt/core/role.md')
expect(result.loadingSemantic).toBe('!')
expect(result.fullReference).toBe('@!package://prompt/core/role.md')
})
test('should parse @? lazy loading semantic', () => {
const result = resolver.parseReference('@?file://large-dataset.csv')
expect(result.protocol).toBe('file')
expect(result.resourcePath).toBe('large-dataset.csv')
expect(result.loadingSemantic).toBe('?')
expect(result.fullReference).toBe('@?file://large-dataset.csv')
})
test('should throw error for invalid reference format', () => {
expect(() => {
resolver.parseReference('invalid-reference')
}).toThrow('Invalid reference format: invalid-reference')
})
test('should throw error for missing protocol', () => {
expect(() => {
resolver.parseReference('://no-protocol')
}).toThrow('Invalid reference format: ://no-protocol')
})
test('should throw error for invalid loading semantic', () => {
expect(() => {
resolver.parseReference('@#package://invalid-semantic')
}).toThrow('Invalid reference format: @#package://invalid-semantic')
})
})
describe('resolve', () => {
test('should resolve @package:// reference to absolute path', async () => {
// Mock the package root finding
jest.spyOn(resolver, 'findPackageRoot').mockResolvedValue('/mock/package/root')
const result = await resolver.resolve('@package://prompt/core/role.md')
expect(result).toBe(path.resolve('/mock/package/root', 'prompt/core/role.md'))
})
test('should resolve @project:// reference to project relative path', async () => {
const result = await resolver.resolve('@project://.promptx/custom.role.md')
expect(result).toBe(path.resolve(process.cwd(), '.promptx/custom.role.md'))
})
test('should resolve @file:// reference with absolute path', async () => {
const result = await resolver.resolve('@file:///absolute/path/to/file.md')
expect(result).toBe('/absolute/path/to/file.md')
})
test('should resolve @file:// reference with relative path', async () => {
const result = await resolver.resolve('@file://relative/path/to/file.md')
expect(result).toBe(path.resolve(process.cwd(), 'relative/path/to/file.md'))
})
test('should throw error for unsupported protocol', async () => {
await expect(resolver.resolve('@unsupported://some/path')).rejects.toThrow('Unsupported protocol: unsupported')
})
})
describe('findPackageRoot', () => {
test('should find package root with promptx package.json', async () => {
// Mock file system operations
const originalExistsSync = fs.existsSync
const originalReadFileSync = fs.readFileSync
fs.existsSync = jest.fn()
fs.readFileSync = jest.fn()
// Mock directory structure
const mockDirname = '/some/deep/nested/path'
resolver.__dirname = mockDirname
// Mock package.json exists in parent directory
fs.existsSync
.mockReturnValueOnce(false) // /some/deep/nested/path/package.json
.mockReturnValueOnce(false) // /some/deep/nested/package.json
.mockReturnValueOnce(false) // /some/deep/package.json
.mockReturnValueOnce(true) // /some/package.json
fs.readFileSync.mockReturnValue(JSON.stringify({ name: 'promptx' }))
// Mock path operations
jest.spyOn(path, 'dirname')
.mockReturnValueOnce('/some/deep/nested')
.mockReturnValueOnce('/some/deep')
.mockReturnValueOnce('/some')
const result = await resolver.findPackageRoot()
expect(result).toBe('/some')
// Restore
fs.existsSync = originalExistsSync
fs.readFileSync = originalReadFileSync
})
test('should throw error when package root not found', async () => {
// Mock file system operations
const originalExistsSync = fs.existsSync
fs.existsSync = jest.fn().mockReturnValue(false)
// Mock reaching root directory
jest.spyOn(path, 'parse').mockReturnValue({ root: '/' })
await expect(resolver.findPackageRoot()).rejects.toThrow('PromptX package root not found')
// Restore
fs.existsSync = originalExistsSync
})
})
describe('caching behavior', () => {
test('should cache package root after first lookup', async () => {
const mockRoot = '/mock/package/root'
jest.spyOn(resolver, 'findPackageRoot').mockResolvedValue(mockRoot)
// First call
await resolver.resolve('@package://prompt/core/role.md')
expect(resolver.findPackageRoot).toHaveBeenCalledTimes(1)
// Second call should use cached value
await resolver.resolve('@package://prompt/domain/java.role.md')
expect(resolver.findPackageRoot).toHaveBeenCalledTimes(1) // Still only called once
})
})
describe('cross-platform compatibility', () => {
test('should handle Windows-style paths correctly', async () => {
jest.spyOn(resolver, 'findPackageRoot').mockResolvedValue('C:\\mock\\package\\root')
const result = await resolver.resolve('@package://prompt\\core\\role.md')
expect(result).toBe(path.resolve('C:\\mock\\package\\root', 'prompt\\core\\role.md'))
})
test('should handle Unix-style paths correctly', async () => {
jest.spyOn(resolver, 'findPackageRoot').mockResolvedValue('/mock/package/root')
const result = await resolver.resolve('@package://prompt/core/role.md')
expect(result).toBe(path.resolve('/mock/package/root', 'prompt/core/role.md'))
})
})
})

View File

@ -0,0 +1,294 @@
const path = require('path')
const { glob } = require('glob')
const ResourceDiscovery = require('../../../lib/core/resource/ResourceDiscovery')
jest.mock('glob')
describe('ResourceDiscovery', () => {
let discovery
beforeEach(() => {
discovery = new ResourceDiscovery()
jest.clearAllMocks()
})
describe('discoverResources', () => {
test('should discover role files and generate correct references', async () => {
const mockScanPaths = [
'/mock/package/prompt',
'/mock/project/.promptx'
]
// Mock process.cwd() for project reference generation
jest.spyOn(process, 'cwd').mockReturnValue('/mock/project')
// Mock glob responses for role files
glob.mockImplementation((pattern) => {
if (pattern.includes('/mock/package/prompt') && pattern.includes('**/*.role.md')) {
return Promise.resolve([
'/mock/package/prompt/domain/java/java-backend-developer.role.md'
])
}
if (pattern.includes('/mock/project/.promptx') && pattern.includes('**/*.role.md')) {
return Promise.resolve([
'/mock/project/.promptx/custom/my-custom.role.md'
])
}
return Promise.resolve([])
})
const discovered = await discovery.discoverResources(mockScanPaths)
const roleResources = discovered.filter(r => r.id.startsWith('role:'))
expect(roleResources).toHaveLength(2)
expect(roleResources[0]).toEqual({
id: 'role:java-backend-developer',
reference: '@package://prompt/domain/java/java-backend-developer.role.md'
})
expect(roleResources[1]).toEqual({
id: 'role:my-custom',
reference: '@project://.promptx/custom/my-custom.role.md'
})
})
test('should discover execution files and generate correct references', async () => {
const mockScanPaths = ['/mock/package/prompt']
glob.mockImplementation((pattern) => {
if (pattern.includes('**/execution/*.execution.md')) {
return Promise.resolve([
'/mock/package/prompt/domain/java/execution/spring-ecosystem.execution.md',
'/mock/package/prompt/core/execution/best-practice.execution.md'
])
}
return Promise.resolve([])
})
const discovered = await discovery.discoverResources(mockScanPaths)
const execResources = discovered.filter(r => r.id.startsWith('execution:'))
expect(execResources).toHaveLength(2)
expect(execResources[0]).toEqual({
id: 'execution:spring-ecosystem',
reference: '@package://prompt/domain/java/execution/spring-ecosystem.execution.md'
})
})
test('should discover thought files and generate correct references', async () => {
const mockScanPaths = ['/mock/package/prompt']
glob.mockImplementation((pattern) => {
if (pattern.includes('**/thought/*.thought.md')) {
return Promise.resolve([
'/mock/package/prompt/core/thought/recall.thought.md',
'/mock/package/prompt/domain/java/thought/java-mindset.thought.md'
])
}
return Promise.resolve([])
})
const discovered = await discovery.discoverResources(mockScanPaths)
const thoughtResources = discovered.filter(r => r.id.startsWith('thought:'))
expect(thoughtResources).toHaveLength(2)
expect(thoughtResources[0]).toEqual({
id: 'thought:recall',
reference: '@package://prompt/core/thought/recall.thought.md'
})
})
test('should discover all resource types in single scan', async () => {
const mockScanPaths = ['/mock/package/prompt']
glob.mockImplementation((pattern) => {
if (pattern.includes('**/*.role.md')) {
return Promise.resolve(['/mock/package/prompt/domain/java.role.md'])
}
if (pattern.includes('**/execution/*.execution.md')) {
return Promise.resolve(['/mock/package/prompt/execution/test.execution.md'])
}
if (pattern.includes('**/thought/*.thought.md')) {
return Promise.resolve(['/mock/package/prompt/thought/test.thought.md'])
}
return Promise.resolve([])
})
const discovered = await discovery.discoverResources(mockScanPaths)
expect(discovered).toHaveLength(3)
expect(discovered.map(r => r.id)).toEqual([
'role:java',
'execution:test',
'thought:test'
])
})
test('should handle empty scan results gracefully', async () => {
const mockScanPaths = ['/empty/path']
glob.mockResolvedValue([])
const discovered = await discovery.discoverResources(mockScanPaths)
expect(discovered).toEqual([])
})
test('should handle multiple scan paths', async () => {
const mockScanPaths = [
'/mock/package/prompt',
'/mock/project/.promptx',
'/mock/user/custom'
]
// Mock process.cwd() for project reference generation
jest.spyOn(process, 'cwd').mockReturnValue('/mock/project')
glob.mockImplementation((pattern) => {
if (pattern.includes('/mock/package/prompt') && pattern.includes('**/*.role.md')) {
return Promise.resolve(['/mock/package/prompt/builtin.role.md'])
}
if (pattern.includes('/mock/project/.promptx') && pattern.includes('**/*.role.md')) {
return Promise.resolve(['/mock/project/.promptx/project.role.md'])
}
if (pattern.includes('/mock/user/custom') && pattern.includes('**/*.role.md')) {
return Promise.resolve(['/mock/user/custom/user.role.md'])
}
return Promise.resolve([])
})
const discovered = await discovery.discoverResources(mockScanPaths)
const roleResources = discovered.filter(r => r.id.startsWith('role:'))
expect(roleResources).toHaveLength(3)
expect(roleResources.map(r => r.reference)).toEqual([
'@package://prompt/builtin.role.md',
'@project://.promptx/project.role.md',
'@file:///mock/user/custom/user.role.md'
])
})
})
describe('extractId', () => {
test('should extract ID from role file path', () => {
const id = discovery.extractId('/path/to/java-backend-developer.role.md', '.role.md')
expect(id).toBe('java-backend-developer')
})
test('should extract ID from execution file path', () => {
const id = discovery.extractId('/path/to/spring-ecosystem.execution.md', '.execution.md')
expect(id).toBe('spring-ecosystem')
})
test('should extract ID from thought file path', () => {
const id = discovery.extractId('/path/to/creative-thinking.thought.md', '.thought.md')
expect(id).toBe('creative-thinking')
})
test('should handle complex file names', () => {
const id = discovery.extractId('/complex/path/with-dashes_and_underscores.role.md', '.role.md')
expect(id).toBe('with-dashes_and_underscores')
})
})
describe('generateReference', () => {
beforeEach(() => {
// Mock findPackageRoot for consistent testing
jest.spyOn(discovery, 'findPackageRoot').mockReturnValue('/mock/package/root')
})
test('should generate @package:// reference for package files', () => {
const reference = discovery.generateReference('/mock/package/root/prompt/core/role.md')
expect(reference).toBe('@package://prompt/core/role.md')
})
test('should generate @project:// reference for project files', () => {
// Mock process.cwd() for consistent testing
jest.spyOn(process, 'cwd').mockReturnValue('/mock/project')
const reference = discovery.generateReference('/mock/project/.promptx/custom.role.md')
expect(reference).toBe('@project://.promptx/custom.role.md')
})
test('should generate @file:// reference for other files', () => {
const reference = discovery.generateReference('/some/other/path/file.md')
expect(reference).toBe('@file:///some/other/path/file.md')
})
test('should handle node_modules/promptx paths correctly', () => {
const reference = discovery.generateReference('/project/node_modules/promptx/prompt/role.md')
expect(reference).toBe('@package://prompt/role.md')
})
test('should handle .promptx directory correctly', () => {
jest.spyOn(process, 'cwd').mockReturnValue('/current/project')
const reference = discovery.generateReference('/current/project/.promptx/my/custom.role.md')
expect(reference).toBe('@project://.promptx/my/custom.role.md')
})
})
describe('findPackageRoot', () => {
test('should find package root from current directory', () => {
// Mock __dirname to simulate being inside the package
discovery.__dirname = '/mock/package/root/src/lib/core/resource'
const root = discovery.findPackageRoot()
expect(root).toBe('/mock/package/root')
})
test('should handle nested paths correctly', () => {
discovery.__dirname = '/very/deep/nested/path/in/package/root/src/lib'
const root = discovery.findPackageRoot()
expect(root).toBe('/very/deep/nested/path/in/package/root/src')
})
})
describe('error handling', () => {
test('should handle glob errors gracefully', async () => {
glob.mockRejectedValue(new Error('Glob failed'))
await expect(discovery.discoverResources(['/bad/path']))
.rejects.toThrow('Glob failed')
})
test('should filter out undefined/null scan paths', async () => {
const scanPaths = [
'/valid/path',
null,
undefined,
'/another/valid/path'
]
glob.mockResolvedValue([])
const discovered = await discovery.discoverResources(scanPaths.filter(Boolean))
// Should only call glob for valid paths
expect(glob).toHaveBeenCalledTimes(6) // 2 valid paths × 3 resource types
})
})
describe('protocol detection logic', () => {
test('should detect package protocol for node_modules/promptx paths', () => {
const reference = discovery.generateReference('/any/path/node_modules/promptx/prompt/test.md')
expect(reference.startsWith('@package://')).toBe(true)
})
test('should detect project protocol for .promptx paths', () => {
jest.spyOn(process, 'cwd').mockReturnValue('/project/root')
const reference = discovery.generateReference('/project/root/.promptx/test.md')
expect(reference.startsWith('@project://')).toBe(true)
})
test('should default to file protocol for unknown paths', () => {
const reference = discovery.generateReference('/unknown/path/test.md')
expect(reference.startsWith('@file://')).toBe(true)
})
})
})

View File

@ -1,224 +1,232 @@
const ResourceManager = require('../../../lib/core/resource/resourceManager')
const fs = require('fs-extra')
const path = require('path')
const os = require('os')
const fs = require('fs')
const { glob } = require('glob')
describe('ResourceManager - 用户资源发现', () => {
let resourceManager
let tempDir
let mockPackageRoot
// Mock dependencies
jest.mock('fs')
jest.mock('glob')
beforeEach(async () => {
// 创建临时测试目录
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'promptx-test-'))
mockPackageRoot = tempDir
describe('ResourceManager - Unit Tests', () => {
let manager
let mockRegistryData
beforeEach(() => {
manager = new ResourceManager()
// 模拟用户资源目录结构
await fs.ensureDir(path.join(tempDir, '.promptx', 'resource', 'domain'))
resourceManager = new ResourceManager()
// Mock packageProtocol module
jest.doMock('../../../lib/core/resource/protocols/PackageProtocol', () => {
return class MockPackageProtocol {
async getPackageRoot() {
return mockPackageRoot
mockRegistryData = {
protocols: {
role: {
registry: {
"java-backend-developer": "@package://prompt/domain/java-backend-developer/java-backend-developer.role.md",
"product-manager": "@package://prompt/domain/product-manager/product-manager.role.md"
}
},
execution: {
registry: {
"spring-ecosystem": "@package://prompt/domain/java-backend-developer/execution/spring-ecosystem.execution.md"
}
},
thought: {
registry: {
"recall": "@package://prompt/core/thought/recall.thought.md"
}
}
}
}
jest.clearAllMocks()
})
describe('新架构核心功能', () => {
test('应该初始化三个核心组件', () => {
expect(manager.registry).toBeDefined()
expect(manager.resolver).toBeDefined()
expect(manager.discovery).toBeDefined()
})
test('应该初始化和加载资源', async () => {
// Mock registry loading
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
// Mock resource discovery
glob.mockResolvedValue([])
await manager.initialize()
expect(fs.readFileSync).toHaveBeenCalledWith('src/resource.registry.json', 'utf8')
expect(manager.registry.index.has('role:java-backend-developer')).toBe(true)
})
test('应该发现并注册动态资源', async () => {
// Mock registry loading
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
// Mock resource discovery
glob.mockImplementation((pattern) => {
if (pattern.includes('**/*.role.md')) {
return Promise.resolve(['/discovered/new-role.role.md'])
}
return Promise.resolve([])
})
await manager.initialize()
// Should have discovered and registered new resource
expect(manager.registry.index.has('role:new-role')).toBe(true)
})
test('应该不覆盖静态注册表', async () => {
// Mock registry loading
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
// Mock discovery returning conflicting resource
glob.mockImplementation((pattern) => {
if (pattern.includes('**/*.role.md')) {
return Promise.resolve(['/discovered/java-backend-developer.role.md'])
}
return Promise.resolve([])
})
await manager.initialize()
// Static registry should take precedence
expect(manager.registry.resolve('java-backend-developer'))
.toBe('@package://prompt/domain/java-backend-developer/java-backend-developer.role.md')
})
})
afterEach(async () => {
// 清理临时目录
await fs.remove(tempDir)
jest.restoreAllMocks()
})
describe('discoverUserResources', () => {
it('应该返回空对象当用户资源目录不存在时', async () => {
// 删除用户资源目录
await fs.remove(path.join(tempDir, '.promptx'))
const result = await resourceManager.discoverUserResources()
expect(result).toEqual({})
describe('资源加载流程', () => {
beforeEach(async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
glob.mockResolvedValue([])
await manager.initialize()
})
it('应该发现用户创建的角色文件', async () => {
// 创建测试角色文件
const roleDir = path.join(tempDir, '.promptx', 'resource', 'domain', 'test-sales-analyst')
await fs.ensureDir(roleDir)
test('应该通过完整流程加载资源', async () => {
const mockContent = '# Java Backend Developer Role\nExpert in Spring ecosystem...'
const roleContent = `<role>
<personality>
# 销售数据分析师思维模式
## 核心思维特征
- **数据敏感性思维**:善于从数字中发现故事和趋势模式
</personality>
<principle>
# 销售数据分析师行为原则
## 核心工作原则
- **数据驱动决策**:所有分析建议必须有可靠数据支撑
</principle>
<knowledge>
# 销售数据分析专业知识体系
## 数据处理技能
- **数据清洗方法**:缺失值处理、异常值识别
</knowledge>
</role>`
// Mock protocol resolver
jest.spyOn(manager.resolver, 'resolve').mockResolvedValue('/resolved/path/java.role.md')
await fs.writeFile(path.join(roleDir, 'test-sales-analyst.role.md'), roleContent)
const result = await resourceManager.discoverUserResources()
expect(result).toHaveProperty('role')
expect(result.role).toHaveProperty('test-sales-analyst')
expect(result.role['test-sales-analyst']).toMatchObject({
file: expect.stringContaining('test-sales-analyst.role.md'),
name: expect.stringContaining('销售数据分析师'),
source: 'user-generated',
format: 'dpml',
type: 'role'
// Mock file reading for loadResource
fs.readFileSync.mockReturnValue(mockContent)
const result = await manager.loadResource('java-backend-developer')
expect(result).toEqual({
content: mockContent,
path: '/resolved/path/java.role.md',
reference: '@package://prompt/domain/java-backend-developer/java-backend-developer.role.md'
})
})
it('应该支持多种资源类型发现', async () => {
// 创建角色和相关资源
const roleDir = path.join(tempDir, '.promptx', 'resource', 'domain', 'test-role')
await fs.ensureDir(roleDir)
await fs.ensureDir(path.join(roleDir, 'thought'))
await fs.ensureDir(path.join(roleDir, 'execution'))
test('应该支持向后兼容的 resolve 方法', async () => {
const mockContent = 'Test content'
// 创建角色文件
await fs.writeFile(path.join(roleDir, 'test-role.role.md'), '<role><personality>Test</personality><principle>Test</principle><knowledge>Test</knowledge></role>')
jest.spyOn(manager.resolver, 'resolve').mockResolvedValue('/resolved/path/file.md')
// 创建思维文件
await fs.writeFile(path.join(roleDir, 'thought', 'test.thought.md'), '<thought><exploration>Test exploration</exploration><reasoning>Test reasoning</reasoning></thought>')
// 创建执行文件
await fs.writeFile(path.join(roleDir, 'execution', 'test.execution.md'), '<execution><constraint>Test constraint</constraint></execution>')
const result = await resourceManager.discoverUserResources()
expect(result).toHaveProperty('role')
expect(result).toHaveProperty('thought')
expect(result).toHaveProperty('execution')
expect(result.role).toHaveProperty('test-role')
expect(result.thought).toHaveProperty('test')
expect(result.execution).toHaveProperty('test')
// Mock file system calls properly for the resolve method
fs.readFileSync.mockImplementation((path) => {
if (path === 'src/resource.registry.json') {
return JSON.stringify(mockRegistryData)
}
return mockContent
})
// Test with @ prefix (direct protocol format)
const result1 = await manager.resolve('@package://test/file.md')
expect(result1.content).toBe(mockContent)
expect(result1.reference).toBe('@package://test/file.md')
// Test without @ prefix (legacy format)
const result2 = await manager.resolve('java-backend-developer')
expect(result2.content).toBe(mockContent)
})
it('应该处理DPML格式错误的文件', async () => {
// 创建格式错误的角色文件
const roleDir = path.join(tempDir, '.promptx', 'resource', 'domain', 'invalid-role')
await fs.ensureDir(roleDir)
const invalidContent = `这不是有效的DPML格式`
await fs.writeFile(path.join(roleDir, 'invalid-role.role.md'), invalidContent)
const result = await resourceManager.discoverUserResources()
// 应该跳过格式错误的文件,但不应该抛出错误
expect(result.role || {}).not.toHaveProperty('invalid-role')
test('应该处理资源未找到错误', async () => {
await expect(manager.loadResource('non-existent-role'))
.rejects.toThrow("Resource 'non-existent-role' not found")
})
it('应该跨平台正确处理路径', async () => {
// 在不同平台上创建角色文件
const roleDir = path.join(tempDir, '.promptx', 'resource', 'domain', 'cross-platform-role')
await fs.ensureDir(roleDir)
const roleContent = '<role><personality>Test</personality><principle>Test</principle><knowledge>Test</knowledge></role>'
await fs.writeFile(path.join(roleDir, 'cross-platform-role.role.md'), roleContent)
const result = await resourceManager.discoverUserResources()
expect(result.role).toHaveProperty('cross-platform-role')
// 验证文件路径使用正确的分隔符
const roleInfo = result.role['cross-platform-role']
expect(roleInfo.file).toBe(path.normalize(roleInfo.file))
test('应该处理协议解析失败', async () => {
jest.spyOn(manager.resolver, 'resolve').mockRejectedValue(new Error('Protocol resolution failed'))
await expect(manager.loadResource('java-backend-developer'))
.rejects.toThrow('Protocol resolution failed')
})
test('应该处理文件读取失败', async () => {
jest.spyOn(manager.resolver, 'resolve').mockResolvedValue('/non/existent/file.md')
fs.readFileSync.mockImplementation(() => {
throw new Error('File not found')
})
await expect(manager.loadResource('java-backend-developer'))
.rejects.toThrow('File not found')
})
})
describe('loadUnifiedRegistry', () => {
it('应该合并系统资源和用户资源', async () => {
// 模拟系统资源使用正确的registry格式
const mockSystemResources = {
protocols: {
role: {
registry: {
'assistant': {
file: '@package://prompt/domain/assistant/assistant.role.md',
name: '🙋 智能助手',
description: '通用助理角色,提供基础的助理服务和记忆支持'
}
}
}
}
}
// Mock fs.readJSON for system registry
jest.spyOn(fs, 'readJSON')
.mockImplementation((filePath) => {
if (filePath.includes('resource.registry.json')) {
return Promise.resolve(mockSystemResources)
}
return Promise.resolve({})
})
// 创建用户资源
const roleDir = path.join(tempDir, '.promptx', 'resource', 'domain', 'user-role')
await fs.ensureDir(roleDir)
await fs.writeFile(
path.join(roleDir, 'user-role.role.md'),
'<role><personality>User</personality><principle>User</principle><knowledge>User</knowledge></role>'
)
const result = await resourceManager.loadUnifiedRegistry()
expect(result.role).toHaveProperty('assistant') // 系统资源
expect(result.role).toHaveProperty('user-role') // 用户资源
describe('环境配置处理', () => {
test('应该处理缺失的环境变量', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
glob.mockResolvedValue([])
// Test with undefined environment variable
delete process.env.PROMPTX_USER_DIR
await manager.initialize()
// Should still work with only static registry
expect(manager.registry.index.has('role:java-backend-developer')).toBe(true)
})
it('应该让用户资源覆盖同名系统资源', async () => {
// 模拟系统资源使用正确的registry格式
const mockSystemResources = {
protocols: {
role: {
registry: {
'assistant': {
file: '@package://prompt/domain/assistant/assistant.role.md',
name: '🙋 智能助手',
description: '通用助理角色,提供基础的助理服务和记忆支持'
}
}
}
test('应该处理多个扫描路径', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
// Mock process.env
process.env.PROMPTX_USER_DIR = '/user/custom'
glob.mockImplementation((pattern) => {
if (pattern.includes('prompt/') && pattern.includes('**/*.role.md')) {
return Promise.resolve(['/package/role.role.md'])
}
}
// Mock fs.readJSON for system registry
jest.spyOn(fs, 'readJSON')
.mockImplementation((filePath) => {
if (filePath.includes('resource.registry.json')) {
return Promise.resolve(mockSystemResources)
}
return Promise.resolve({})
})
// 创建同名的用户资源
const roleDir = path.join(tempDir, '.promptx', 'resource', 'domain', 'assistant')
await fs.ensureDir(roleDir)
await fs.writeFile(
path.join(roleDir, 'assistant.role.md'),
'<role><personality># 自定义助手\n用户定制的助手</personality><principle>Custom</principle><knowledge>Custom</knowledge></role>'
)
const result = await resourceManager.loadUnifiedRegistry()
expect(result.role.assistant.source).toBe('user-generated')
expect(result.role.assistant.name).toContain('自定义助手')
if (pattern.includes('.promptx/') && pattern.includes('**/*.role.md')) {
return Promise.resolve(['/project/role.role.md'])
}
if (pattern.includes('/user/custom') && pattern.includes('**/*.role.md')) {
return Promise.resolve(['/user/role.role.md'])
}
return Promise.resolve([])
})
await manager.initialize()
// Should discover from all paths
expect(manager.registry.index.has('role:role')).toBe(true)
})
})
})
describe('错误处理和边界情况', () => {
test('应该处理注册表加载失败', async () => {
fs.readFileSync.mockImplementation(() => {
throw new Error('Registry file not found')
})
await expect(manager.initialize()).rejects.toThrow('Registry file not found')
})
test('应该处理发现失败', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
glob.mockRejectedValue(new Error('Discovery failed'))
await expect(manager.initialize()).rejects.toThrow('Discovery failed')
})
test('应该处理格式错误的注册表', async () => {
fs.readFileSync.mockReturnValue('invalid json')
glob.mockResolvedValue([])
await expect(manager.initialize()).rejects.toThrow()
})
})
})

View File

@ -1,133 +1,249 @@
const ResourceManager = require('../../../lib/core/resource/resourceManager')
const fs = require('fs').promises
const fs = require('fs')
const path = require('path')
const os = require('os')
const { glob } = require('glob')
// Mock dependencies for integration testing
jest.mock('fs')
jest.mock('glob')
describe('ResourceManager - Integration Tests', () => {
let manager
let mockRegistryData
beforeEach(() => {
manager = new ResourceManager()
// Mock registry data matching the new format
mockRegistryData = {
protocols: {
role: {
registry: {
"java-backend-developer": "@package://prompt/domain/java-backend-developer/java-backend-developer.role.md",
"product-manager": "@package://prompt/domain/product-manager/product-manager.role.md"
}
},
execution: {
registry: {
"spring-ecosystem": "@package://prompt/domain/java-backend-developer/execution/spring-ecosystem.execution.md",
"code-quality": "@package://prompt/domain/java-backend-developer/execution/code-quality.execution.md"
}
},
thought: {
registry: {
"recall": "@package://prompt/core/thought/recall.thought.md",
"remember": "@package://prompt/core/thought/remember.thought.md"
}
}
}
}
jest.clearAllMocks()
})
describe('基础功能测试', () => {
test('应该初始化ResourceManager', async () => {
await manager.initialize()
expect(manager.initialized).toBe(true)
})
describe('新架构集成测试', () => {
test('应该完整初始化所有核心组件', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
glob.mockResolvedValue([])
test('应该加载统一资源注册表', async () => {
await manager.initialize()
expect(manager.registry).toBeDefined()
expect(manager.registry.protocols).toBeDefined()
expect(manager.resolver).toBeDefined()
expect(manager.discovery).toBeDefined()
expect(manager.registry.index.size).toBeGreaterThan(0)
})
test('应该注册协议处理器', async () => {
await manager.initialize()
expect(manager.protocolHandlers.size).toBeGreaterThan(0)
expect(manager.protocolHandlers.has('package')).toBe(true)
expect(manager.protocolHandlers.has('project')).toBe(true)
expect(manager.protocolHandlers.has('prompt')).toBe(true)
})
})
describe('资源解析功能', () => {
test('应该处理无效的资源URL格式', async () => {
const result = await manager.resolve('invalid-reference')
expect(result.success).toBe(false)
expect(result.error.message).toContain('无效的资源URL格式')
})
test('应该处理未注册的协议', async () => {
const result = await manager.resolve('@unknown://test')
expect(result.success).toBe(false)
expect(result.error.message).toContain('未注册的协议')
})
test('应该解析package协议资源', async () => {
const result = await manager.resolve('@package://package.json')
expect(result.success).toBe(true)
expect(result.metadata.protocol).toBe('package')
})
test('应该解析prompt协议资源', async () => {
const result = await manager.resolve('@prompt://protocols')
// prompt协议可能找不到匹配文件但应该不抛出解析错误
if (!result.success) {
expect(result.error.message).toContain('没有找到匹配的文件')
} else {
expect(result.metadata.protocol).toBe('prompt')
}
})
})
describe('工具方法', () => {
test('应该获取可用协议列表', async () => {
await manager.initialize()
const protocols = manager.getAvailableProtocols()
expect(Array.isArray(protocols)).toBe(true)
expect(protocols.length).toBeGreaterThan(0)
expect(protocols).toContain('package')
expect(protocols).toContain('prompt')
})
test('应该获取协议信息', async () => {
await manager.initialize()
const info = manager.getProtocolInfo('package')
expect(info).toBeDefined()
expect(info.name).toBe('package')
})
test('应该获取协议注册表', async () => {
await manager.initialize()
const registry = manager.getProtocolRegistry('prompt')
if (registry) {
expect(typeof registry).toBe('object')
}
})
})
describe('查询参数解析', () => {
test('应该解析带查询参数的资源', async () => {
const result = await manager.resolve('@package://package.json?key=name')
expect(result.success).toBe(true)
expect(result.metadata.protocol).toBe('package')
})
test('应该解析加载语义', async () => {
const result = await manager.resolve('@!package://package.json')
expect(result.success).toBe(true)
expect(result.metadata.protocol).toBe('package')
expect(result.metadata.loadingSemantic).toBe('@!')
})
})
describe('错误处理', () => {
test('应该正确处理资源不存在的情况', async () => {
const result = await manager.resolve('@package://nonexistent.json')
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
test('未初始化时应该抛出错误', async () => {
const uninitializedManager = new ResourceManager()
test('应该从静态注册表和动态发现加载资源', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
try {
await uninitializedManager.getProtocolRegistry('package')
fail('应该抛出错误')
} catch (error) {
expect(error.message).toContain('ResourceManager未初始化')
}
// Mock discovery finding additional resources
glob.mockImplementation((pattern) => {
if (pattern.includes('**/*.role.md')) {
return Promise.resolve(['/discovered/new-role.role.md'])
}
return Promise.resolve([])
})
await manager.initialize()
// Should have both static and discovered resources
expect(manager.registry.index.has('role:java-backend-developer')).toBe(true)
expect(manager.registry.index.has('role:new-role')).toBe(true)
})
test('应该优先使用静态注册表而非动态发现', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
// Mock discovery finding conflicting resource
glob.mockImplementation((pattern) => {
if (pattern.includes('**/*.role.md')) {
return Promise.resolve(['/discovered/java-backend-developer.role.md'])
}
return Promise.resolve([])
})
await manager.initialize()
// Static registry should take precedence
const reference = manager.registry.resolve('java-backend-developer')
expect(reference).toBe('@package://prompt/domain/java-backend-developer/java-backend-developer.role.md')
})
})
describe('完整资源加载流程', () => {
beforeEach(async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
glob.mockResolvedValue([])
await manager.initialize()
})
test('应该执行完整的资源加载流程', async () => {
const mockContent = '# Java Backend Developer Role\n专业的Java后端开发者...'
const mockFilePath = '/resolved/path/java-backend-developer.role.md'
// Mock the protocol resolver
jest.spyOn(manager.resolver, 'resolve').mockResolvedValue(mockFilePath)
// Mock file reading for content
fs.readFileSync.mockReturnValue(mockContent)
const result = await manager.loadResource('java-backend-developer')
expect(result.content).toBe(mockContent)
expect(result.path).toBe(mockFilePath)
expect(result.reference).toBe('@package://prompt/domain/java-backend-developer/java-backend-developer.role.md')
})
test('应该支持向后兼容的resolve方法', async () => {
const mockContent = 'Test content'
const mockFilePath = '/test/path/file.md'
jest.spyOn(manager.resolver, 'resolve').mockResolvedValue(mockFilePath)
// Mock file system calls properly for the resolve method
fs.readFileSync.mockImplementation((path) => {
if (path === 'src/resource.registry.json') {
return JSON.stringify(mockRegistryData)
}
return mockContent
})
// Test direct protocol format
const result1 = await manager.resolve('@package://test/file.md')
expect(result1.content).toBe(mockContent)
expect(result1.reference).toBe('@package://test/file.md')
// Test legacy ID format
const result2 = await manager.resolve('java-backend-developer')
expect(result2.content).toBe(mockContent)
})
test('应该处理多种资源类型', async () => {
const mockContent = 'Resource content'
const mockFilePath = '/test/path'
jest.spyOn(manager.resolver, 'resolve').mockResolvedValue(mockFilePath)
fs.readFileSync.mockReturnValue(mockContent)
// Test role resource
const roleResult = await manager.loadResource('java-backend-developer')
expect(roleResult.reference).toContain('role.md')
// Test execution resource
const execResult = await manager.loadResource('spring-ecosystem')
expect(execResult.reference).toContain('execution.md')
// Test thought resource
const thoughtResult = await manager.loadResource('recall')
expect(thoughtResult.reference).toContain('thought.md')
})
})
describe('错误处理和边界情况', () => {
test('应该处理资源不存在的情况', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
glob.mockResolvedValue([])
await manager.initialize()
await expect(manager.loadResource('non-existent-resource'))
.rejects.toThrow("Resource 'non-existent-resource' not found")
})
test('应该处理协议解析失败', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
glob.mockResolvedValue([])
await manager.initialize()
jest.spyOn(manager.resolver, 'resolve').mockRejectedValue(new Error('Protocol resolution failed'))
await expect(manager.loadResource('java-backend-developer'))
.rejects.toThrow('Protocol resolution failed')
})
test('应该处理文件读取失败', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
glob.mockResolvedValue([])
await manager.initialize()
jest.spyOn(manager.resolver, 'resolve').mockResolvedValue('/non/existent/file.md')
fs.readFileSync.mockImplementation((path) => {
if (path === 'src/resource.registry.json') {
return JSON.stringify(mockRegistryData)
}
throw new Error('File not found')
})
await expect(manager.loadResource('java-backend-developer'))
.rejects.toThrow('File not found')
})
test('应该处理初始化失败', async () => {
fs.readFileSync.mockImplementation(() => {
throw new Error('Registry file not found')
})
await expect(manager.initialize()).rejects.toThrow('Registry file not found')
})
})
describe('环境和路径处理', () => {
test('应该处理多个扫描路径', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
// Set environment variable
process.env.PROMPTX_USER_DIR = '/user/custom'
glob.mockImplementation((pattern) => {
if (pattern.includes('prompt/') && pattern.includes('**/*.role.md')) {
return Promise.resolve(['/package/test.role.md'])
}
if (pattern.includes('.promptx/') && pattern.includes('**/*.role.md')) {
return Promise.resolve(['/project/test.role.md'])
}
if (pattern.includes('/user/custom') && pattern.includes('**/*.role.md')) {
return Promise.resolve(['/user/test.role.md'])
}
return Promise.resolve([])
})
await manager.initialize()
// Should discover from all scan paths
expect(manager.registry.index.has('role:test')).toBe(true)
})
test('应该处理缺失的环境变量', async () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
glob.mockResolvedValue([])
// Remove environment variable
delete process.env.PROMPTX_USER_DIR
await manager.initialize()
// Should still work with package and project paths
expect(manager.registry.index.has('role:java-backend-developer')).toBe(true)
})
})
})

View File

@ -1,134 +1,179 @@
const ResourceRegistry = require('../../../lib/core/resource/resourceRegistry')
const { ProtocolInfo } = require('../../../lib/core/resource/types')
const fs = require('fs')
// Mock fs for testing
jest.mock('fs')
describe('ResourceRegistry - Unit Tests', () => {
let registry
let mockRegistryData
beforeEach(() => {
registry = new ResourceRegistry()
})
describe('内置协议', () => {
test('应该包含内置协议', () => {
const protocols = registry.listProtocols()
expect(protocols).toContain('prompt')
expect(protocols).toContain('file')
expect(protocols).toContain('memory')
})
test('应该正确获取prompt协议信息', () => {
const protocolInfo = registry.getProtocolInfo('prompt')
expect(protocolInfo).toBeDefined()
expect(protocolInfo.name).toBe('prompt')
expect(protocolInfo.description).toContain('PromptX内置提示词资源协议')
expect(protocolInfo.location).toContain('prompt://')
})
test('应该为协议提供资源注册表', () => {
const protocolInfo = registry.getProtocolInfo('memory')
expect(protocolInfo.registry).toBeDefined()
expect(protocolInfo.registry.size).toBeGreaterThan(0)
expect(protocolInfo.registry.has('declarative')).toBe(true)
expect(protocolInfo.registry.has('procedural')).toBe(true)
})
})
describe('资源解析', () => {
test('应该解析prompt协议的资源ID', () => {
const resolved = registry.resolve('prompt', 'protocols')
expect(resolved).toBe('@package://prompt/protocol/**/*.md')
})
test('应该解析memory协议的资源ID', () => {
const resolved = registry.resolve('memory', 'declarative')
expect(resolved).toBe('@project://.promptx/memory/declarative.md')
})
test('应该解析未注册协议的资源路径', () => {
const resolved = registry.resolve('file', 'any/path.md')
expect(resolved).toBe('any/path.md')
})
test('应该在资源ID不存在时抛出错误', () => {
expect(() => registry.resolve('prompt', 'nonexistent')).toThrow('Resource ID \'nonexistent\' not found in prompt protocol registry')
})
})
describe('自定义协议注册', () => {
test('应该注册新的自定义协议', () => {
const customProtocol = {
description: '测试协议',
location: 'test://{resource_id}',
registry: {
test1: '@file://test1.md',
test2: '@file://test2.md'
// Mock registry data
mockRegistryData = {
protocols: {
role: {
registry: {
"java-backend-developer": "@package://prompt/domain/java-backend-developer/java-backend-developer.role.md",
"product-manager": "@package://prompt/domain/product-manager/product-manager.role.md"
}
},
execution: {
registry: {
"spring-ecosystem": "@package://prompt/domain/java-backend-developer/execution/spring-ecosystem.execution.md"
}
},
thought: {
registry: {
"recall": "@package://prompt/core/thought/recall.thought.md"
}
}
}
}
registry.register('test', customProtocol)
jest.clearAllMocks()
})
expect(registry.hasProtocol('test')).toBe(true)
expect(registry.resolve('test', 'test1')).toBe('@file://test1.md')
describe('新架构核心功能', () => {
test('应该初始化为空索引', () => {
expect(registry.index).toBeInstanceOf(Map)
expect(registry.index.size).toBe(0)
})
test('应该列出自定义协议的资源', () => {
const customProtocol = {
registry: {
resource1: '@file://r1.md',
resource2: '@file://r2.md'
test('应该从文件加载注册表', () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
registry.loadFromFile('test-registry.json')
expect(registry.index.has('role:java-backend-developer')).toBe(true)
expect(registry.index.has('execution:spring-ecosystem')).toBe(true)
expect(registry.index.has('thought:recall')).toBe(true)
})
test('应该注册新资源', () => {
registry.register('role:test-role', '@package://test/role.md')
expect(registry.index.get('role:test-role')).toBe('@package://test/role.md')
})
test('应该解析资源ID到引用', () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
registry.loadFromFile()
const reference = registry.resolve('role:java-backend-developer')
expect(reference).toBe('@package://prompt/domain/java-backend-developer/java-backend-developer.role.md')
})
test('应该支持向后兼容的ID解析', () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
registry.loadFromFile()
// Should resolve without protocol prefix (backward compatibility)
const reference = registry.resolve('java-backend-developer')
expect(reference).toBe('@package://prompt/domain/java-backend-developer/java-backend-developer.role.md')
})
test('应该处理协议优先级', () => {
registry.register('role:test', '@package://role/test.md')
registry.register('thought:test', '@package://thought/test.md')
// Should return role protocol first (higher priority)
const reference = registry.resolve('test')
expect(reference).toBe('@package://role/test.md')
})
test('应该在资源未找到时抛出错误', () => {
expect(() => {
registry.resolve('non-existent-resource')
}).toThrow("Resource 'non-existent-resource' not found")
})
})
describe('文件格式兼容性', () => {
test('应该处理字符串格式的资源信息', () => {
const stringFormatData = {
protocols: {
role: {
registry: {
"simple-role": "@package://simple.role.md"
}
}
}
}
fs.readFileSync.mockReturnValue(JSON.stringify(stringFormatData))
registry.loadFromFile()
expect(registry.resolve('simple-role')).toBe('@package://simple.role.md')
})
registry.register('custom', customProtocol)
const resources = registry.listProtocolResources('custom')
test('应该处理对象格式的资源信息', () => {
const objectFormatData = {
protocols: {
role: {
registry: {
"complex-role": {
file: "@package://complex.role.md",
description: "Complex role description"
}
}
}
}
}
fs.readFileSync.mockReturnValue(JSON.stringify(objectFormatData))
registry.loadFromFile()
expect(registry.resolve('complex-role')).toBe('@package://complex.role.md')
})
expect(resources).toContain('resource1')
expect(resources).toContain('resource2')
test('应该处理缺失协议部分', () => {
fs.readFileSync.mockReturnValue(JSON.stringify({}))
registry.loadFromFile()
expect(registry.index.size).toBe(0)
})
test('应该处理空注册表', () => {
const emptyData = {
protocols: {
role: {},
execution: { registry: {} }
}
}
fs.readFileSync.mockReturnValue(JSON.stringify(emptyData))
registry.loadFromFile()
expect(registry.index.size).toBe(0)
})
})
describe('验证功能', () => {
test('应该验证有效的协议和资源ID', () => {
expect(registry.validateReference('prompt', 'protocols')).toBe(true)
expect(registry.validateReference('file', 'any-path.md')).toBe(true)
expect(registry.validateReference('memory', 'declarative')).toBe(true)
describe('错误处理', () => {
test('应该处理格式错误的JSON', () => {
fs.readFileSync.mockReturnValue('invalid json')
expect(() => {
registry.loadFromFile()
}).toThrow()
})
test('应该拒绝无效的协议和资源ID', () => {
expect(registry.validateReference('unknown', 'test')).toBe(false)
expect(registry.validateReference('prompt', 'nonexistent')).toBe(false)
test('应该覆盖现有注册', () => {
registry.register('role:test', '@package://old.md')
registry.register('role:test', '@package://new.md')
expect(registry.resolve('role:test')).toBe('@package://new.md')
})
test('应该使用默认注册表路径', () => {
fs.readFileSync.mockReturnValue(JSON.stringify(mockRegistryData))
registry.loadFromFile()
expect(fs.readFileSync).toHaveBeenCalledWith('src/resource.registry.json', 'utf8')
})
})
describe('注册表信息', () => {
test('应该返回完整的注册表信息', () => {
const info = registry.getRegistryInfo()
expect(info.builtin).toHaveProperty('prompt')
expect(info.builtin).toHaveProperty('file')
expect(info.builtin).toHaveProperty('memory')
expect(info.custom).toEqual({})
})
test('应该返回协议的资源列表', () => {
const resources = registry.listProtocolResources('prompt')
expect(resources).toContain('protocols')
expect(resources).toContain('core')
expect(resources).toContain('domain')
expect(resources).toContain('bootstrap')
})
test('应该为无注册表的协议返回空列表', () => {
const resources = registry.listProtocolResources('file')
expect(resources).toEqual([])
})
})
})
})

View File

@ -124,7 +124,8 @@ describe('协议路径警告问题 - E2E Tests', () => {
}
} catch (error) {
// 验证错误信息是否与问题描述匹配
expect(error.message).toMatch(/协议|路径|@packages/)
// 在新架构中,错误消息应该是 "Resource 'prompt' not found"
expect(error.message).toMatch(/Resource.*not found|协议|路径|@packages/)
}
} finally {
@ -265,23 +266,24 @@ describe('协议路径警告问题 - E2E Tests', () => {
})
describe('协议注册表验证测试', () => {
test('应该验证prompt协议注册表配置', () => {
test('应该验证prompt协议注册表配置', async () => {
const ResourceRegistry = require('../../lib/core/resource/resourceRegistry')
const registry = new ResourceRegistry()
// 检查prompt协议是否正确注册
const promptProtocol = registry.getProtocolInfo('prompt')
expect(promptProtocol).toBeDefined()
expect(promptProtocol.name).toBe('prompt')
// 在新架构中,注册表是基于索引的,检查是否正确加载
await registry.loadFromFile('src/resource.registry.json')
expect(registry.index.size).toBeGreaterThan(0)
// 检查protocols资源是否在注册表中
const protocolRegistry = registry.getProtocolRegistry('prompt')
expect(protocolRegistry).toBeDefined()
expect(protocolRegistry.has('protocols')).toBe(true)
// 检查一些基础资源是否正确注册
const hasRoleResource = Array.from(registry.index.keys()).some(key => key.startsWith('role:'))
const hasExecutionResource = Array.from(registry.index.keys()).some(key => key.startsWith('execution:'))
expect(hasRoleResource).toBe(true)
expect(hasExecutionResource).toBe(true)
// 获取protocols的路径配置
const protocolsPath = protocolRegistry.get('protocols')
expect(protocolsPath).toBe('@package://prompt/protocol/**/*.md')
// 检查注册表是否包含协议引用格式
const registryEntries = Array.from(registry.index.values())
const hasPackageProtocol = registryEntries.some(ref => ref.startsWith('@package://'))
expect(hasPackageProtocol).toBe(true)
console.log('✅ 协议注册表配置验证通过')
})